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    total
463}
464
465/// Info about an analyzed track, passed to the progress callback.
466pub struct AnalysisEvent<'a> {
467    pub path: &'a str,
468    pub success: bool,
469    pub current: usize,
470    pub total: usize,
471}
472
473/// Run acoustic analysis on all tracks missing vectors.
474/// Uses rayon for parallel analysis, stores results sequentially.
475pub fn analyze_missing(
476    db: &Database,
477    on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
478) -> (usize, usize) {
479    let missing = match queries::tracks_missing_vectors(&db.conn) {
480        Ok(m) => m,
481        Err(e) => {
482            log::error!("failed to query missing vectors: {}", e);
483            return (0, 0);
484        }
485    };
486
487    if missing.is_empty() {
488        return (0, 0);
489    }
490
491    let total = missing.len();
492    log::info!("analyzing {} tracks for acoustic features", total);
493
494    // Analyze in parallel.
495    let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
496        .par_iter()
497        .enumerate()
498        .map(|(i, (track_id, path))| {
499            let result = match catch_unwind(AssertUnwindSafe(|| {
500                features::analyze_track(Path::new(path))
501            })) {
502                Ok(r) => r,
503                Err(_) => Err(features::AnalysisError::Bliss(format!(
504                    "panicked while analyzing {}",
505                    path
506                ))),
507            };
508            if let Some(cb) = &on_track {
509                cb(AnalysisEvent {
510                    path,
511                    success: result.is_ok(),
512                    current: i + 1,
513                    total,
514                });
515            }
516            (*track_id, path.clone(), result)
517        })
518        .collect();
519
520    // Store sequentially.
521    let mut analyzed = 0usize;
522    let mut errors = 0usize;
523    let tx = match db.conn.unchecked_transaction() {
524        Ok(tx) => tx,
525        Err(e) => {
526            log::error!("failed to begin analysis transaction: {}", e);
527            return (0, 0);
528        }
529    };
530    for (track_id, path, result) in results {
531        match result {
532            Ok(embedding) => {
533                if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
534                    log::warn!("failed to store vector for {}: {}", path, e);
535                    errors += 1;
536                } else {
537                    analyzed += 1;
538                }
539            }
540            Err(e) => {
541                log::warn!("analysis failed for {}: {}", path, e);
542                errors += 1;
543            }
544        }
545    }
546    if let Err(e) = tx.commit() {
547        log::error!("failed to commit analysis transaction: {}", e);
548    }
549
550    log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
551    (analyzed, errors)
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::db::connection::Database;
558    use crate::db::queries;
559    use crate::test_utils;
560
561    fn test_db(dir: &Path) -> Database {
562        let db_path = dir.join("test.db");
563        Database::open(&db_path).unwrap()
564    }
565
566    #[test]
567    fn scan_folder_indexes_new_files() {
568        let dir = tempfile::tempdir().unwrap();
569        let music_dir = dir.path().join("music");
570        std::fs::create_dir_all(&music_dir).unwrap();
571
572        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
573        let wav_path = music_dir.join("silence.wav");
574        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
575
576        let db = test_db(dir.path());
577        let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
578
579        assert_eq!(result.added, 1, "expected 1 track added");
580        assert_eq!(result.skipped, 0);
581        assert_eq!(result.removed, 0);
582        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
583
584        // Verify the track exists in the DB.
585        let stats = queries::library_stats(&db.conn).unwrap();
586        assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
587    }
588
589    #[test]
590    fn import_paths_indexes_files_where_they_lie() {
591        let dir = tempfile::tempdir().unwrap();
592        // Deliberately nothing to do with a library folder — this is the
593        // drag-a-rip-onto-the-queue case.
594        let drop = dir.path().join("Downloads/rip");
595        std::fs::create_dir_all(&drop).unwrap();
596        test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
597        test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
598        std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
599
600        let db = test_db(dir.path());
601        let result = import_paths(&db, std::slice::from_ref(&drop));
602
603        assert_eq!(result.added, 2);
604        assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
605        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
606
607        // The rows point at where the files still are; organize is what moves them.
608        for id in &result.track_ids {
609            let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
610            assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
611        }
612    }
613
614    /// Dropping the same rip twice queues it again without duplicating rows.
615    #[test]
616    fn import_paths_is_idempotent() {
617        let dir = tempfile::tempdir().unwrap();
618        let drop = dir.path().join("rip");
619        std::fs::create_dir_all(&drop).unwrap();
620        test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
621
622        let db = test_db(dir.path());
623        let first = import_paths(&db, std::slice::from_ref(&drop));
624        let second = import_paths(&db, std::slice::from_ref(&drop));
625
626        assert_eq!(first.added, 1);
627        assert_eq!(second.added, 0);
628        assert_eq!(second.updated, 1);
629        assert_eq!(first.track_ids, second.track_ids);
630        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
631    }
632
633    /// A drop can name a folder and a file inside it; the file is imported once.
634    #[test]
635    fn import_paths_deduplicates_overlapping_selections() {
636        let dir = tempfile::tempdir().unwrap();
637        let drop = dir.path().join("rip");
638        std::fs::create_dir_all(&drop).unwrap();
639        let track = drop.join("a.wav");
640        test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
641
642        let db = test_db(dir.path());
643        let result = import_paths(&db, &[drop.clone(), track.clone()]);
644
645        assert_eq!(result.track_ids.len(), 1);
646    }
647
648    #[test]
649    fn scan_folder_skips_unchanged_files() {
650        let dir = tempfile::tempdir().unwrap();
651        let music_dir = dir.path().join("music");
652        std::fs::create_dir_all(&music_dir).unwrap();
653
654        let wav_path = music_dir.join("unchanged.wav");
655        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
656
657        let db = test_db(dir.path());
658
659        // First scan: adds the file.
660        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
661        assert_eq!(r1.added, 1);
662
663        // Second scan: file unchanged, should be skipped.
664        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
665        assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
666        assert_eq!(r2.added, 0, "no new files should be added");
667    }
668
669    #[test]
670    fn scan_folder_removes_deleted_tracks() {
671        let dir = tempfile::tempdir().unwrap();
672        let music_dir = dir.path().join("music");
673        std::fs::create_dir_all(&music_dir).unwrap();
674
675        let wav_path = music_dir.join("ephemeral.wav");
676        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
677        test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
678
679        let db = test_db(dir.path());
680
681        // First scan: adds both files.
682        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
683        assert_eq!(r1.added, 2);
684
685        // Delete one of them.
686        std::fs::remove_file(&wav_path).unwrap();
687
688        // Second scan: should detect removal.
689        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
690        assert_eq!(
691            r2.removed, 1,
692            "expected 1 track removed after file deletion"
693        );
694
695        let stats = queries::library_stats(&db.conn).unwrap();
696        assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
697    }
698
699    #[test]
700    fn empty_folder_does_not_wipe_the_library() {
701        let dir = tempfile::tempdir().unwrap();
702        let music_dir = dir.path().join("music");
703        std::fs::create_dir_all(&music_dir).unwrap();
704        test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
705        test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
706
707        let db = test_db(dir.path());
708        assert_eq!(
709            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
710            2
711        );
712
713        // The folder is still there but yields nothing — an unmounted NAS, a
714        // detached volume, a Docker volume that failed to attach.
715        std::fs::remove_file(music_dir.join("a.wav")).unwrap();
716        std::fs::remove_file(music_dir.join("b.wav")).unwrap();
717
718        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
719        assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
720        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
721    }
722
723    #[cfg(unix)]
724    #[test]
725    fn unreadable_folder_is_not_a_deletion() {
726        use std::os::unix::fs::PermissionsExt;
727
728        let dir = tempfile::tempdir().unwrap();
729        let music_dir = dir.path().join("music");
730        let locked_dir = music_dir.join("locked");
731        std::fs::create_dir_all(&locked_dir).unwrap();
732        test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
733        let locked_file = locked_dir.join("locked.wav");
734        test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
735
736        let db = test_db(dir.path());
737        assert_eq!(
738            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
739            2
740        );
741
742        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
743        if locked_file.try_exists().is_ok() {
744            // Running as root — the permission bits mean nothing here.
745            std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
746            return;
747        }
748
749        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
750        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
751
752        assert!(
753            r.unreadable >= 1,
754            "the unreadable subtree should be counted"
755        );
756        assert_eq!(r.removed, 0, "an IO error is not a deletion");
757        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
758    }
759
760    #[test]
761    fn interrupted_scan_keeps_committed_chunks_and_resumes() {
762        let dir = tempfile::tempdir().unwrap();
763        let music_dir = dir.path().join("music");
764        std::fs::create_dir_all(&music_dir).unwrap();
765        for i in 0..6 {
766            test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
767        }
768
769        let db = test_db(dir.path());
770
771        // Abort partway through the second chunk, the way Ctrl-C would.
772        let seen = std::cell::Cell::new(0usize);
773        let abort = |_: ScanEvent| {
774            seen.set(seen.get() + 1);
775            assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
776        };
777        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
778            scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
779        }));
780        assert!(panicked.is_err());
781
782        // The first chunk is on disk; the interrupted one is not.
783        assert_eq!(
784            queries::library_stats(&db.conn).unwrap().total_tracks,
785            CHUNK_SIZE as i64
786        );
787
788        // And the next run picks up where it left off instead of restarting.
789        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
790        assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
791        assert_eq!(r.added, 6 - CHUNK_SIZE);
792        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
793    }
794
795    #[test]
796    fn failing_file_is_named_and_the_scan_continues() {
797        let dir = tempfile::tempdir().unwrap();
798        let music_dir = dir.path().join("music");
799        std::fs::create_dir_all(&music_dir).unwrap();
800        test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
801        let broken = music_dir.join("broken.flac");
802        std::fs::write(&broken, b"").unwrap();
803
804        let db = test_db(dir.path());
805        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
806
807        assert_eq!(r.added, 1, "the good file must still be indexed");
808        assert_eq!(r.errors.len(), 1);
809        assert_eq!(r.errors[0].0, broken, "the failing file must be named");
810        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
811    }
812
813    #[test]
814    fn a_panicking_tag_read_becomes_an_error() {
815        let path = Path::new("/music/hostile.mp3");
816        let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
817        assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
818    }
819
820    #[test]
821    fn scan_folder_updates_modified_files() {
822        let dir = tempfile::tempdir().unwrap();
823        let music_dir = dir.path().join("music");
824        std::fs::create_dir_all(&music_dir).unwrap();
825
826        let wav_path = music_dir.join("modified.wav");
827        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
828
829        let db = test_db(dir.path());
830
831        // First scan.
832        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
833        assert_eq!(r1.added, 1);
834
835        // Modify the file (rewrite with different duration → different size + mtime).
836        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
837        std::thread::sleep(std::time::Duration::from_millis(1100));
838        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
839
840        // Second scan: should detect the modification.
841        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
842        assert_eq!(r2.updated, 1, "modified file should be re-indexed");
843        assert_eq!(r2.added, 0, "the row already exists");
844        assert_eq!(r2.skipped, 0, "modified file should not be skipped");
845    }
846}