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    let mut result = ScanResult::default();
77
78    // Collect audio files via walkdir. `follow_links` means a symlink pointing at
79    // a sibling directory inside the library indexes its files under both paths.
80    let mut audio_files: Vec<PathBuf> = Vec::new();
81    for entry in walkdir::WalkDir::new(path).follow_links(true) {
82        match entry {
83            Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
84                audio_files.push(e.path().to_path_buf())
85            }
86            Ok(_) => {}
87            Err(e) => {
88                result.unreadable += 1;
89                log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
90            }
91        }
92    }
93
94    let total_files = audio_files.len();
95    log::info!("found {} audio files in {}", total_files, path.display());
96
97    // Filter to files that need scanning.
98    // Batch-load the entire scan_cache into a HashMap to avoid O(N) individual
99    // DB lookups (one per file). For 100k+ file libraries this is dramatically faster.
100    let files_to_scan: Vec<PathBuf> = if opts.force {
101        std::mem::take(&mut audio_files)
102    } else {
103        let scan_cache = queries::load_scan_cache(&db.conn).unwrap_or_default();
104        audio_files
105            .iter()
106            .filter(|file_path| {
107                let Ok(file_meta) = std::fs::metadata(file_path) else {
108                    return true;
109                };
110                let mtime = file_meta
111                    .modified()
112                    .ok()
113                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
114                    .map(|d| d.as_secs() as i64)
115                    .unwrap_or(0);
116                let size = file_meta.len() as i64;
117                let path_str = file_path.to_string_lossy();
118                match scan_cache.get(path_str.as_ref()) {
119                    Some(&(cached_mtime, cached_size)) => {
120                        mtime != cached_mtime || size != cached_size
121                    }
122                    None => true,
123                }
124            })
125            .cloned()
126            .collect()
127    };
128
129    result.skipped = total_files - files_to_scan.len();
130
131    // Tag reads and database writes run at the same time.
132    //
133    // Reading the whole library up front and writing it in one transaction
134    // blocks every other writer for the length of the scan and loses all of it
135    // on interrupt, so writes stay chunked. But doing that as read-chunk,
136    // write-chunk, read-chunk leaves the disk idle for every write and the CPU
137    // idle for every read — on a library of any size that is most of the run.
138    //
139    // Instead the reads stream: a worker pool walks every file and pushes
140    // results down a bounded channel while this thread batches them into
141    // transactions. The bound is what caps memory, in place of the chunking.
142    let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
143        CHUNK_SIZE.saturating_mul(2),
144    );
145    let reader = std::thread::Builder::new()
146        .name("koan-scan-read".into())
147        .spawn(move || {
148            files_to_scan.par_iter().for_each(|file_path| {
149                // A send error means the consumer is gone; nothing left to do.
150                let _ = send.send((
151                    file_path.clone(),
152                    isolate_read(file_path, metadata::read_metadata),
153                ));
154            });
155        });
156    if let Err(e) = &reader {
157        log::error!("failed to spawn scan reader: {}", e);
158        result
159            .errors
160            .push((path.to_path_buf(), format!("scan error: {}", e)));
161        return result;
162    }
163
164    loop {
165        // Blocks until a full batch is ready or the readers have finished.
166        let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
167            recv.iter().take(CHUNK_SIZE).collect();
168        if batch.is_empty() {
169            break;
170        }
171        if opts
172            .cancel
173            .as_ref()
174            .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
175        {
176            log::info!("scan cancelled — keeping what was already committed");
177            result.cancelled = true;
178            break;
179        }
180
181        let tx = match db.conn.unchecked_transaction() {
182            Ok(tx) => tx,
183            Err(e) => {
184                log::error!("failed to begin scan transaction: {}", e);
185                result
186                    .errors
187                    .push((path.to_path_buf(), format!("db error: {}", e)));
188                return result;
189            }
190        };
191
192        let (mut added, mut updated) = (0usize, 0usize);
193        for (file_path, meta_result) in batch {
194            match meta_result {
195                Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
196                    Ok((track_id, is_new)) => {
197                        if is_new {
198                            added += 1;
199                        } else {
200                            updated += 1;
201                        }
202                        if let Some(cb) = &on_track {
203                            cb(ScanEvent {
204                                artist: &meta.artist,
205                                album: &meta.album,
206                                title: &meta.title,
207                                path: &file_path,
208                                is_new,
209                            });
210                        }
211                        if let Err(e) = queries::update_scan_cache(
212                            &tx,
213                            meta.path.as_deref().unwrap_or(""),
214                            meta.mtime.unwrap_or(0),
215                            meta.size_bytes.unwrap_or(0),
216                            track_id,
217                        ) {
218                            // Not fatal, but every future scan re-reads this file's tags.
219                            log::warn!("failed to cache {}: {}", file_path.display(), e);
220                        }
221                    }
222                    Err(e) => {
223                        result.errors.push((file_path, format!("db error: {}", e)));
224                    }
225                },
226                Err(e) => {
227                    result.errors.push((file_path, e));
228                }
229            }
230        }
231
232        match tx.commit() {
233            Ok(()) => {
234                result.added += added;
235                result.updated += updated;
236            }
237            Err(e) => {
238                log::error!("failed to commit scan transaction: {}", e);
239                result
240                    .errors
241                    .push((path.to_path_buf(), format!("db error: {}", e)));
242            }
243        }
244    }
245
246    if let Ok(handle) = reader
247        && handle.join().is_err()
248    {
249        log::error!("scan reader thread panicked");
250    }
251
252    if result.cancelled {
253        // Stale removal decides what is missing by what the scan did *not* see.
254        // After a cancellation that is most of the folder, so it would delete a
255        // library rather than tidy one.
256        return result;
257    }
258
259    // Remove tracks for files that no longer exist. A folder that yielded nothing
260    // is far more likely to be an unmounted volume than a library someone emptied,
261    // and stale rows are recoverable where deleted play history is not.
262    if total_files == 0 {
263        log::error!(
264            "{} contains no audio files — skipping stale-track removal. \
265             If this folder should have music in it, it is probably not mounted or not readable.",
266            path.display()
267        );
268        return result;
269    }
270
271    let tx = match db.conn.unchecked_transaction() {
272        Ok(tx) => tx,
273        Err(e) => {
274            log::error!("failed to begin stale-removal transaction: {}", e);
275            result
276                .errors
277                .push((path.to_path_buf(), format!("db error: {}", e)));
278            return result;
279        }
280    };
281    match queries::remove_stale_tracks(&tx, path, opts.force_remove) {
282        Ok(removed) => {
283            result.removed = removed.len();
284            result.removed_paths = removed;
285            if let Err(e) = tx.commit() {
286                log::error!("failed to commit stale removals: {}", e);
287                result.removed = 0;
288                result.removed_paths.clear();
289                result
290                    .errors
291                    .push((path.to_path_buf(), format!("db error: {}", e)));
292            }
293        }
294        Err(e) => {
295            log::error!("failed to remove stale tracks: {}", e);
296            result.errors.push((path.to_path_buf(), e.to_string()));
297        }
298    }
299
300    result
301}
302
303/// Run a tag read, containing a panic from the parsers. Hostile input (a bogus
304/// ID3v2 frame size, a pathological MP4 atom tree) can panic inside lofty or
305/// symphonia; rayon re-raises that at `collect()`, which would otherwise abort
306/// the whole scan over one file and not even name it.
307fn isolate_read(
308    path: &Path,
309    read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
310) -> Result<TrackMeta, String> {
311    match catch_unwind(AssertUnwindSafe(|| read(path))) {
312        Ok(result) => result.map_err(|e| e.to_string()),
313        Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
314    }
315}
316
317/// What an import of specific files produced.
318#[derive(Debug, Default)]
319pub struct ImportResult {
320    /// Library rows for the imported files, in the order their paths were
321    /// walked. This is what a caller queues.
322    pub track_ids: Vec<i64>,
323    pub added: usize,
324    pub updated: usize,
325    pub errors: Vec<(PathBuf, String)>,
326}
327
328/// Index specific files into the library, wherever they live.
329///
330/// This is the drop-a-folder-on-the-queue path: the files named here are not
331/// under a configured library folder, and organize is what moves them there
332/// afterwards. Nothing is ever removed — the caller named these paths, so there
333/// is no directory listing to reconcile against and nothing to prune, which is
334/// what separates this from `scan_folder`.
335///
336/// Directories are walked recursively. Order is by path, so an album lands in
337/// the order its files are numbered.
338pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
339    let mut result = ImportResult::default();
340
341    let mut files: Vec<PathBuf> = Vec::new();
342    let mut seen = std::collections::HashSet::new();
343    for path in paths {
344        let mut found: Vec<PathBuf> = walkdir::WalkDir::new(path)
345            .follow_links(true)
346            .into_iter()
347            .filter_map(Result::ok)
348            .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
349            .map(|e| e.path().to_path_buf())
350            .collect();
351        found.sort();
352        // A drop can name both a folder and a file inside it.
353        files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
354    }
355
356    if files.is_empty() {
357        return result;
358    }
359
360    // Tag reads are the slow part and independent per file; the writes are not.
361    let read: Vec<(PathBuf, Result<TrackMeta, String>)> = files
362        .par_iter()
363        .map(|path| (path.clone(), isolate_read(path, metadata::read_metadata)))
364        .collect();
365
366    let tx = match db.conn.unchecked_transaction() {
367        Ok(tx) => tx,
368        Err(e) => {
369            result
370                .errors
371                .push((PathBuf::new(), format!("db error: {e}")));
372            return result;
373        }
374    };
375
376    for (path, meta_result) in read {
377        let meta = match meta_result {
378            Ok(meta) => meta,
379            Err(e) => {
380                result.errors.push((path, e));
381                continue;
382            }
383        };
384        match queries::upsert_track_status(&tx, &meta) {
385            Ok((track_id, is_new)) => {
386                if is_new {
387                    result.added += 1;
388                } else {
389                    result.updated += 1;
390                }
391                result.track_ids.push(track_id);
392                if let Err(e) = queries::update_scan_cache(
393                    &tx,
394                    meta.path.as_deref().unwrap_or(""),
395                    meta.mtime.unwrap_or(0),
396                    meta.size_bytes.unwrap_or(0),
397                    track_id,
398                ) {
399                    // Not fatal, but every future scan re-reads this file's tags.
400                    log::warn!("failed to cache {}: {}", path.display(), e);
401                }
402            }
403            Err(e) => result.errors.push((path, format!("db error: {e}"))),
404        }
405    }
406
407    if let Err(e) = tx.commit() {
408        result.track_ids.clear();
409        result.added = 0;
410        result.updated = 0;
411        result
412            .errors
413            .push((PathBuf::new(), format!("db error: {e}")));
414    }
415
416    result
417}
418
419/// How many audio files these folders hold.
420///
421/// A directory walk with no tag reads — cheap next to the scan it precedes, and
422/// the only way to report a fraction rather than a spinner.
423pub fn count_audio_files(folders: &[PathBuf]) -> u64 {
424    folders
425        .iter()
426        .flat_map(|folder| {
427            walkdir::WalkDir::new(folder)
428                .follow_links(true)
429                .into_iter()
430                .filter_map(Result::ok)
431        })
432        .filter(|e| e.file_type().is_file() && metadata::is_audio_file(e.path()))
433        .count() as u64
434}
435
436/// Scan all configured library folders.
437pub fn full_scan(
438    db: &Database,
439    folders: &[PathBuf],
440    opts: ScanOptions,
441    on_track: Option<&dyn Fn(ScanEvent)>,
442) -> ScanResult {
443    let mut total = ScanResult::default();
444    for folder in folders {
445        if total.cancelled {
446            break;
447        }
448        if !folder.exists() {
449            log::warn!("library folder does not exist: {}", folder.display());
450            continue;
451        }
452        let r = scan_folder(db, folder, opts.clone(), on_track);
453        total.cancelled |= r.cancelled;
454        total.added += r.added;
455        total.updated += r.updated;
456        total.removed += r.removed;
457        total.skipped += r.skipped;
458        total.unreadable += r.unreadable;
459        total.removed_paths.extend(r.removed_paths);
460        total.errors.extend(r.errors);
461    }
462    db.optimize();
463    total
464}
465
466/// Info about an analyzed track, passed to the progress callback.
467pub struct AnalysisEvent<'a> {
468    pub path: &'a str,
469    pub success: bool,
470    pub current: usize,
471    pub total: usize,
472}
473
474/// Run acoustic analysis on all tracks missing vectors.
475/// Uses rayon for parallel analysis, stores results sequentially.
476pub fn analyze_missing(
477    db: &Database,
478    on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
479) -> (usize, usize) {
480    let missing = match queries::tracks_missing_vectors(&db.conn) {
481        Ok(m) => m,
482        Err(e) => {
483            log::error!("failed to query missing vectors: {}", e);
484            return (0, 0);
485        }
486    };
487
488    if missing.is_empty() {
489        return (0, 0);
490    }
491
492    let total = missing.len();
493    log::info!("analyzing {} tracks for acoustic features", total);
494
495    // Analyze in parallel.
496    let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
497        .par_iter()
498        .enumerate()
499        .map(|(i, (track_id, path))| {
500            let result = match catch_unwind(AssertUnwindSafe(|| {
501                features::analyze_track(Path::new(path))
502            })) {
503                Ok(r) => r,
504                Err(_) => Err(features::AnalysisError::Bliss(format!(
505                    "panicked while analyzing {}",
506                    path
507                ))),
508            };
509            if let Some(cb) = &on_track {
510                cb(AnalysisEvent {
511                    path,
512                    success: result.is_ok(),
513                    current: i + 1,
514                    total,
515                });
516            }
517            (*track_id, path.clone(), result)
518        })
519        .collect();
520
521    // Store sequentially.
522    let mut analyzed = 0usize;
523    let mut errors = 0usize;
524    let tx = match db.conn.unchecked_transaction() {
525        Ok(tx) => tx,
526        Err(e) => {
527            log::error!("failed to begin analysis transaction: {}", e);
528            return (0, 0);
529        }
530    };
531    for (track_id, path, result) in results {
532        match result {
533            Ok(embedding) => {
534                if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
535                    log::warn!("failed to store vector for {}: {}", path, e);
536                    errors += 1;
537                } else {
538                    analyzed += 1;
539                }
540            }
541            Err(e) => {
542                log::warn!("analysis failed for {}: {}", path, e);
543                errors += 1;
544            }
545        }
546    }
547    if let Err(e) = tx.commit() {
548        log::error!("failed to commit analysis transaction: {}", e);
549    }
550
551    log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
552    (analyzed, errors)
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::db::connection::Database;
559    use crate::db::queries;
560    use crate::test_utils;
561
562    fn test_db(dir: &Path) -> Database {
563        let db_path = dir.join("test.db");
564        Database::open(&db_path).unwrap()
565    }
566
567    #[test]
568    fn scan_folder_indexes_new_files() {
569        let dir = tempfile::tempdir().unwrap();
570        let music_dir = dir.path().join("music");
571        std::fs::create_dir_all(&music_dir).unwrap();
572
573        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
574        let wav_path = music_dir.join("silence.wav");
575        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
576
577        let db = test_db(dir.path());
578        let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
579
580        assert_eq!(result.added, 1, "expected 1 track added");
581        assert_eq!(result.skipped, 0);
582        assert_eq!(result.removed, 0);
583        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
584
585        // Verify the track exists in the DB.
586        let stats = queries::library_stats(&db.conn).unwrap();
587        assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
588    }
589
590    #[test]
591    fn import_paths_indexes_files_where_they_lie() {
592        let dir = tempfile::tempdir().unwrap();
593        // Deliberately nothing to do with a library folder — this is the
594        // drag-a-rip-onto-the-queue case.
595        let drop = dir.path().join("Downloads/rip");
596        std::fs::create_dir_all(&drop).unwrap();
597        test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
598        test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
599        std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
600
601        let db = test_db(dir.path());
602        let result = import_paths(&db, std::slice::from_ref(&drop));
603
604        assert_eq!(result.added, 2);
605        assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
606        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
607
608        // The rows point at where the files still are; organize is what moves them.
609        for id in &result.track_ids {
610            let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
611            assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
612        }
613    }
614
615    /// Dropping the same rip twice queues it again without duplicating rows.
616    #[test]
617    fn import_paths_is_idempotent() {
618        let dir = tempfile::tempdir().unwrap();
619        let drop = dir.path().join("rip");
620        std::fs::create_dir_all(&drop).unwrap();
621        test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
622
623        let db = test_db(dir.path());
624        let first = import_paths(&db, std::slice::from_ref(&drop));
625        let second = import_paths(&db, std::slice::from_ref(&drop));
626
627        assert_eq!(first.added, 1);
628        assert_eq!(second.added, 0);
629        assert_eq!(second.updated, 1);
630        assert_eq!(first.track_ids, second.track_ids);
631        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
632    }
633
634    /// A drop can name a folder and a file inside it; the file is imported once.
635    #[test]
636    fn import_paths_deduplicates_overlapping_selections() {
637        let dir = tempfile::tempdir().unwrap();
638        let drop = dir.path().join("rip");
639        std::fs::create_dir_all(&drop).unwrap();
640        let track = drop.join("a.wav");
641        test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
642
643        let db = test_db(dir.path());
644        let result = import_paths(&db, &[drop.clone(), track.clone()]);
645
646        assert_eq!(result.track_ids.len(), 1);
647    }
648
649    #[test]
650    fn scan_folder_skips_unchanged_files() {
651        let dir = tempfile::tempdir().unwrap();
652        let music_dir = dir.path().join("music");
653        std::fs::create_dir_all(&music_dir).unwrap();
654
655        let wav_path = music_dir.join("unchanged.wav");
656        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
657
658        let db = test_db(dir.path());
659
660        // First scan: adds the file.
661        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
662        assert_eq!(r1.added, 1);
663
664        // Second scan: file unchanged, should be skipped.
665        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
666        assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
667        assert_eq!(r2.added, 0, "no new files should be added");
668    }
669
670    #[test]
671    fn scan_folder_removes_deleted_tracks() {
672        let dir = tempfile::tempdir().unwrap();
673        let music_dir = dir.path().join("music");
674        std::fs::create_dir_all(&music_dir).unwrap();
675
676        let wav_path = music_dir.join("ephemeral.wav");
677        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
678        test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
679
680        let db = test_db(dir.path());
681
682        // First scan: adds both files.
683        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
684        assert_eq!(r1.added, 2);
685
686        // Delete one of them.
687        std::fs::remove_file(&wav_path).unwrap();
688
689        // Second scan: should detect removal.
690        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
691        assert_eq!(
692            r2.removed, 1,
693            "expected 1 track removed after file deletion"
694        );
695
696        let stats = queries::library_stats(&db.conn).unwrap();
697        assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
698    }
699
700    #[test]
701    fn empty_folder_does_not_wipe_the_library() {
702        let dir = tempfile::tempdir().unwrap();
703        let music_dir = dir.path().join("music");
704        std::fs::create_dir_all(&music_dir).unwrap();
705        test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
706        test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
707
708        let db = test_db(dir.path());
709        assert_eq!(
710            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
711            2
712        );
713
714        // The folder is still there but yields nothing — an unmounted NAS, a
715        // detached volume, a Docker volume that failed to attach.
716        std::fs::remove_file(music_dir.join("a.wav")).unwrap();
717        std::fs::remove_file(music_dir.join("b.wav")).unwrap();
718
719        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
720        assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
721        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
722    }
723
724    #[cfg(unix)]
725    #[test]
726    fn unreadable_folder_is_not_a_deletion() {
727        use std::os::unix::fs::PermissionsExt;
728
729        let dir = tempfile::tempdir().unwrap();
730        let music_dir = dir.path().join("music");
731        let locked_dir = music_dir.join("locked");
732        std::fs::create_dir_all(&locked_dir).unwrap();
733        test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
734        let locked_file = locked_dir.join("locked.wav");
735        test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
736
737        let db = test_db(dir.path());
738        assert_eq!(
739            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
740            2
741        );
742
743        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
744        if locked_file.try_exists().is_ok() {
745            // Running as root — the permission bits mean nothing here.
746            std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
747            return;
748        }
749
750        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
751        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
752
753        assert!(
754            r.unreadable >= 1,
755            "the unreadable subtree should be counted"
756        );
757        assert_eq!(r.removed, 0, "an IO error is not a deletion");
758        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
759    }
760
761    #[test]
762    fn interrupted_scan_keeps_committed_chunks_and_resumes() {
763        let dir = tempfile::tempdir().unwrap();
764        let music_dir = dir.path().join("music");
765        std::fs::create_dir_all(&music_dir).unwrap();
766        for i in 0..6 {
767            test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
768        }
769
770        let db = test_db(dir.path());
771
772        // Abort partway through the second chunk, the way Ctrl-C would.
773        let seen = std::cell::Cell::new(0usize);
774        let abort = |_: ScanEvent| {
775            seen.set(seen.get() + 1);
776            assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
777        };
778        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
779            scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
780        }));
781        assert!(panicked.is_err());
782
783        // The first chunk is on disk; the interrupted one is not.
784        assert_eq!(
785            queries::library_stats(&db.conn).unwrap().total_tracks,
786            CHUNK_SIZE as i64
787        );
788
789        // And the next run picks up where it left off instead of restarting.
790        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
791        assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
792        assert_eq!(r.added, 6 - CHUNK_SIZE);
793        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
794    }
795
796    #[test]
797    fn failing_file_is_named_and_the_scan_continues() {
798        let dir = tempfile::tempdir().unwrap();
799        let music_dir = dir.path().join("music");
800        std::fs::create_dir_all(&music_dir).unwrap();
801        test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
802        let broken = music_dir.join("broken.flac");
803        std::fs::write(&broken, b"").unwrap();
804
805        let db = test_db(dir.path());
806        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
807
808        assert_eq!(r.added, 1, "the good file must still be indexed");
809        assert_eq!(r.errors.len(), 1);
810        assert_eq!(r.errors[0].0, broken, "the failing file must be named");
811        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
812    }
813
814    #[test]
815    fn a_panicking_tag_read_becomes_an_error() {
816        let path = Path::new("/music/hostile.mp3");
817        let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
818        assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
819    }
820
821    #[test]
822    fn scan_folder_updates_modified_files() {
823        let dir = tempfile::tempdir().unwrap();
824        let music_dir = dir.path().join("music");
825        std::fs::create_dir_all(&music_dir).unwrap();
826
827        let wav_path = music_dir.join("modified.wav");
828        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
829
830        let db = test_db(dir.path());
831
832        // First scan.
833        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
834        assert_eq!(r1.added, 1);
835
836        // Modify the file (rewrite with different duration → different size + mtime).
837        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
838        std::thread::sleep(std::time::Duration::from_millis(1100));
839        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
840
841        // Second scan: should detect the modification.
842        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
843        assert_eq!(r2.updated, 1, "modified file should be re-indexed");
844        assert_eq!(r2.added, 0, "the row already exists");
845        assert_eq!(r2.skipped, 0, "modified file should not be skipped");
846    }
847}