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