Skip to main content

koan_core/index/
scanner.rs

1use std::collections::HashSet;
2use std::panic::{AssertUnwindSafe, catch_unwind};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6use std::time::UNIX_EPOCH;
7
8use rayon::prelude::*;
9
10use crate::db::connection::Database;
11use crate::db::queries::{self, TrackMeta};
12
13use super::metadata::{self, is_audio_file};
14use super::playlist_files::is_playlist_file;
15
16/// Result of a folder scan.
17#[derive(Debug, Default)]
18pub struct ScanResult {
19    pub added: usize,
20    pub updated: usize,
21    pub removed: usize,
22    /// Files found at a new path that kept the track they had at the old one,
23    /// and so are counted neither as added nor as removed.
24    pub moved: usize,
25    pub skipped: usize,
26    /// Directory entries walkdir could not read — unreadable subtrees, symlink
27    /// loops. Their contents are absent from the scan entirely.
28    pub unreadable: usize,
29    /// Playlist files read into new or changed playlists.
30    pub playlists: usize,
31    /// Paths of the tracks deleted or demoted to remote-only, so a caller can
32    /// show what a removal actually took.
33    pub removed_paths: Vec<String>,
34    pub errors: Vec<(PathBuf, String)>,
35    /// Stopped early because someone asked. What it had done is still done.
36    pub cancelled: bool,
37    /// Tracks this scan made, which a file gone from elsewhere may turn out to
38    /// be (see `queries::adopt_moved_files`).
39    arrived: Vec<i64>,
40}
41
42/// How a scan should behave.
43#[derive(Debug, Clone, Default)]
44pub struct ScanOptions {
45    /// Re-read tags for every file, ignoring `scan_cache`.
46    pub force: bool,
47    /// Set from another thread to stop early.
48    ///
49    /// Checked between transactions, so a cancelled scan keeps everything it
50    /// had already committed rather than throwing the work away — stopping is
51    /// "stop here", not "undo".
52    pub cancel: Option<Arc<AtomicBool>>,
53    /// Delete stale tracks even when the proportion missing looks like a mount
54    /// failure. Lifts the removal-fraction brake only — a folder that yields no
55    /// audio files is still left alone, and an IO error still never counts as
56    /// "file gone".
57    pub force_remove: bool,
58}
59
60/// Files per transaction. Bounds peak memory (only one chunk's metadata is
61/// resident) and caps what an interrupted scan loses; committed chunks land in
62/// `scan_cache`, so the next run resumes rather than restarting.
63#[cfg(not(test))]
64const CHUNK_SIZE: usize = 1000;
65#[cfg(test)]
66const CHUNK_SIZE: usize = 4;
67
68/// Info about a scanned track, passed to the progress callback.
69pub struct ScanEvent<'a> {
70    pub artist: &'a str,
71    pub album: &'a str,
72    pub title: &'a str,
73    pub path: &'a Path,
74    pub is_new: bool,
75}
76
77/// Scan a folder recursively for audio files and index them into the database.
78/// The optional `on_track` callback is invoked for each successfully indexed track.
79pub fn scan_folder(
80    db: &Database,
81    path: &Path,
82    opts: ScanOptions,
83    on_track: Option<&dyn Fn(ScanEvent)>,
84) -> ScanResult {
85    scan_folders(
86        db,
87        std::slice::from_ref(&path.to_path_buf()),
88        opts,
89        None,
90        on_track,
91    )
92}
93
94/// [`scan_folder`] over several folders, telling `on_started` how many audio
95/// files there are once every folder has been walked. The count comes from the
96/// same walk the scan uses: a separate counting walk would double the directory
97/// traversal, which on a network mount is most of the cost of a rescan.
98pub fn scan_folders(
99    db: &Database,
100    folders: &[PathBuf],
101    mut opts: ScanOptions,
102    on_started: Option<&dyn Fn(u64)>,
103    on_track: Option<&dyn Fn(ScanEvent)>,
104) -> ScanResult {
105    let _turn = super::lane::enter(folders, &mut opts);
106    scan_folders_in_lane(db, folders, &opts, on_started, on_track)
107}
108
109fn scan_folders_in_lane(
110    db: &Database,
111    folders: &[PathBuf],
112    opts: &ScanOptions,
113    on_started: Option<&dyn Fn(u64)>,
114    on_track: Option<&dyn Fn(ScanEvent)>,
115) -> ScanResult {
116    let walked: Vec<(PathBuf, Walked, ScanResult)> = folders
117        .iter()
118        .map(|folder| {
119            // The files under it are stored as the directory spells them; the
120            // root has to agree, or nothing under it matches a path an earlier
121            // scan stored.
122            let path = library_folder(db, folder);
123            let mut result = ScanResult::default();
124            let found = walk(&path, &mut result);
125            (path, found, result)
126        })
127        .collect();
128    if let Some(started) = on_started {
129        started(
130            walked
131                .iter()
132                .map(|(_, found, _)| found.audio.len() as u64)
133                .sum(),
134        );
135    }
136
137    // Every folder is indexed before any is pruned, so a file moved from one
138    // library folder to another is found at its new path before it is missed
139    // at its old one, and keeps its track.
140    let mut indexed = Vec::new();
141    let mut cancelled = false;
142    for (path, found, mut result) in walked {
143        if cancelled {
144            break;
145        }
146        // A folder with no audio is taken for one not mounted, as stale
147        // removal takes it: its playlists are read but none forgotten.
148        let settled = if found.audio.is_empty() || result.unreadable > 0 {
149            Vec::new()
150        } else {
151            vec![path.clone()]
152        };
153        let seen = stored_paths(&found.audio);
154        let prune = index_folder(db, &path, found.audio, opts, on_track, &mut result);
155        cancelled |= result.cancelled;
156        indexed.push((path, found.playlists, settled, prune, seen, result));
157    }
158
159    let arrived: Vec<i64> = indexed
160        .iter()
161        .flat_map(|(.., result)| result.arrived.iter().copied())
162        .collect();
163    let mut total = ScanResult::default();
164    for (path, playlists, settled, prune, seen, mut result) in indexed {
165        if prune && !cancelled {
166            remove_stale(db, &path, opts.force_remove, &arrived, &seen, &mut result);
167        }
168        if !result.cancelled {
169            result.playlists += super::playlist_files::import(db, &playlists, &settled);
170        }
171        merge(&mut total, result);
172    }
173    // A moved file was counted as added when its new path was indexed.
174    total.added = total.added.saturating_sub(total.moved);
175    if total.added > 0 {
176        super::playlist_files::refresh_m3u(db);
177    }
178    total
179}
180
181/// Index a library folder's files. Whether its stale rows may then be
182/// removed.
183fn index_folder(
184    db: &Database,
185    path: &Path,
186    audio_files: Vec<PathBuf>,
187    opts: &ScanOptions,
188    on_track: Option<&dyn Fn(ScanEvent)>,
189    result: &mut ScanResult,
190) -> bool {
191    let total_files = audio_files.len();
192    log::info!("found {} audio files in {}", total_files, path.display());
193
194    let scope = [path.to_path_buf()];
195    if !index_files(db, audio_files, &scope, opts, on_track, result, path) {
196        return false;
197    }
198
199    if result.cancelled {
200        // Stale removal decides what is missing by what the scan did *not* see.
201        // After a cancellation that is most of the folder, so it would delete a
202        // library rather than tidy one.
203        return false;
204    }
205
206    // Remove tracks for files that no longer exist. A folder that yielded nothing
207    // is far more likely to be an unmounted volume than a library someone emptied,
208    // and stale rows are recoverable where deleted play history is not.
209    if total_files == 0 {
210        log::error!(
211            "{} contains no audio files — skipping stale-track removal. \
212             If this folder should have music in it, it is probably not mounted or not readable.",
213            path.display()
214        );
215        return false;
216    }
217    true
218}
219
220/// Rescan directories inside the library folders, and nothing else.
221///
222/// What the folder watcher runs: a change touches a handful of directories,
223/// and walking the whole library to find them costs a spinning disk minutes.
224/// Each directory is authoritative for what lies under it — files found are
225/// indexed, rows for files no longer there are removed — which is what
226/// separates this from `import_paths`. A directory that no longer exists
227/// removes everything that was under it.
228///
229/// Removal is only trusted while the library folder holding the directory is
230/// itself readable and non-empty: an unmounted volume leaves an empty mount
231/// point, and every directory under it would otherwise read as deleted. A
232/// directory that could not be read in full, or one reached through a symlink
233/// whose target is gone, removes nothing. Directories
234/// outside every library folder are ignored; one that is a library folder
235/// gets the same treatment as `scan_folder`.
236pub fn scan_dirs(
237    db: &Database,
238    library: &[PathBuf],
239    dirs: &[PathBuf],
240    mut opts: ScanOptions,
241    on_track: Option<&dyn Fn(ScanEvent)>,
242) -> ScanResult {
243    let _turn = super::lane::enter(dirs, &mut opts);
244    let mut result = ScanResult::default();
245    let mut spelling = super::spelling::Spelling::default();
246    let library: Vec<PathBuf> = library.iter().map(|f| library_folder(db, f)).collect();
247    let dirs: Vec<PathBuf> = dirs.iter().map(|d| spelling.on_disk(d)).collect();
248
249    let mut files = Vec::new();
250    let mut playlists = Vec::new();
251    let mut walked = Vec::new();
252    let mut settled = Vec::new();
253    for dir in minimal_dirs(dirs) {
254        let Some(root) = library.iter().find(|root| dir.starts_with(root)) else {
255            log::warn!("not in a library folder, not scanning: {}", dir.display());
256            continue;
257        };
258        if dir == *root {
259            merge(
260                &mut result,
261                scan_folders_in_lane(db, std::slice::from_ref(root), &opts, None, on_track),
262            );
263            continue;
264        }
265        if !is_populated(root) {
266            log::warn!(
267                "{} is empty or unreadable — not scanning {}",
268                root.display(),
269                dir.display()
270            );
271            continue;
272        }
273        match dir.try_exists() {
274            Ok(true) => {
275                let before = result.unreadable;
276                let found = walk(&dir, &mut result);
277                files.extend(found.audio);
278                playlists.extend(found.playlists);
279                walked.push(dir.clone());
280                if result.unreadable == before {
281                    settled.push(dir);
282                }
283            }
284            Ok(false) if super::known_missing(&dir) => settled.push(dir),
285            Ok(false) => log::warn!(
286                "{} is behind a link that leads nowhere — not scanning it",
287                dir.display()
288            ),
289            Err(e) => log::warn!("cannot tell whether {} exists: {e}", dir.display()),
290        }
291    }
292
293    let seen = stored_paths(&files);
294    let indexed = index_files(
295        db,
296        files,
297        &walked,
298        &opts,
299        on_track,
300        &mut result,
301        Path::new(""),
302    );
303    if !indexed || result.cancelled {
304        return result;
305    }
306    let arrived = std::mem::take(&mut result.arrived);
307    for dir in &settled {
308        remove_stale(db, dir, true, &arrived, &seen, &mut result);
309        // A cover image changing is a reason to be here that no row records.
310        if let Err(e) = queries::evict_art_under(&db.conn, dir) {
311            log::warn!("cover art under {} not refreshed: {e}", dir.display());
312        }
313    }
314    result.playlists += super::playlist_files::import(db, &playlists, &settled);
315    result.added = result.added.saturating_sub(result.moved);
316    if result.added > 0 {
317        super::playlist_files::refresh_m3u(db);
318    }
319    result
320}
321
322/// Walked files as their rows store them.
323fn stored_paths(files: &[PathBuf]) -> HashSet<String> {
324    files
325        .iter()
326        .map(|f| f.to_string_lossy().into_owned())
327        .collect()
328}
329
330/// The fewest directories that cover every one given: duplicates dropped, and
331/// any directory inside another dropped, since a scan of the outer one walks it.
332pub fn minimal_dirs(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
333    // Sorted by component, everything inside a directory comes straight after it.
334    dirs.sort();
335    dirs.dedup();
336    let mut kept: Vec<PathBuf> = Vec::with_capacity(dirs.len());
337    for dir in dirs {
338        if !kept.last().is_some_and(|outer| dir.starts_with(outer)) {
339            kept.push(dir);
340        }
341    }
342    kept
343}
344
345/// A library folder as the disk spells it, with the rows stored under the
346/// spelling it was configured with moved there first
347/// (`sources::respell_folder`).
348fn library_folder(db: &Database, folder: &Path) -> PathBuf {
349    let path = super::spelling::on_disk(folder);
350    if path != folder {
351        let respelled = queries::write_transaction(&db.conn)
352            .map_err(crate::db::connection::DbError::from)
353            .and_then(|tx| {
354                queries::sources::respell_folder(&tx, folder, &path)?;
355                Ok(tx.commit()?)
356            });
357        if let Err(e) = respelled {
358            log::warn!(
359                "could not move {} to {}: {e}",
360                folder.display(),
361                path.display()
362            );
363        }
364    }
365    path
366}
367
368/// Whether a library folder is there to scan: readable, with something in it.
369fn is_populated(root: &Path) -> bool {
370    std::fs::read_dir(root).is_ok_and(|mut entries| entries.next().is_some())
371}
372
373fn merge(total: &mut ScanResult, r: ScanResult) {
374    total.cancelled |= r.cancelled;
375    total.added += r.added;
376    total.updated += r.updated;
377    total.removed += r.removed;
378    total.moved += r.moved;
379    total.skipped += r.skipped;
380    total.unreadable += r.unreadable;
381    total.playlists += r.playlists;
382    total.removed_paths.extend(r.removed_paths);
383    total.errors.extend(r.errors);
384}
385
386/// What a walk of a directory found.
387#[derive(Default)]
388struct Walked {
389    audio: Vec<PathBuf>,
390    /// Playlist files: see `playlist_files`.
391    playlists: Vec<PathBuf>,
392}
393
394/// Every audio and playlist file under `path`. `follow_links` means a symlink
395/// pointing at a sibling directory inside the library indexes its files under
396/// both paths.
397///
398/// A path that is not UTF-8 is left out: the database stores paths as text, so
399/// it would be stored under a name no stat finds, and the same scan would
400/// remove it again along with its play history. So is anything under a name
401/// the watcher ignores (see [`super::watch::is_ignored`]), below `path` itself.
402fn walk(path: &Path, result: &mut ScanResult) -> Walked {
403    let mut found = Walked::default();
404    let entries = walkdir::WalkDir::new(path)
405        .follow_links(true)
406        .into_iter()
407        .filter_entry(|e| e.depth() == 0 || !super::watch::is_ignored(e.file_name()));
408    for entry in entries {
409        match entry {
410            Ok(e) if e.path().to_str().is_none() => {
411                if e.file_type().is_file() {
412                    log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
413                }
414            }
415            Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
416                found.audio.push(e.path().to_path_buf())
417            }
418            Ok(e) if e.file_type().is_file() && is_playlist_file(e.path()) => {
419                found.playlists.push(e.path().to_path_buf())
420            }
421            Ok(_) => {}
422            Err(e) => {
423                result.unreadable += 1;
424                log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
425            }
426        }
427    }
428    found
429}
430
431/// Read and store the files that changed since they were last indexed.
432/// `scope` holds the directories and files they were walked from: the scan
433/// cache is read for those alone.
434///
435/// Returns false when the scan could not run at all, with the reason in
436/// `result.errors` against `context`.
437fn index_files(
438    db: &Database,
439    mut audio_files: Vec<PathBuf>,
440    scope: &[PathBuf],
441    opts: &ScanOptions,
442    on_track: Option<&dyn Fn(ScanEvent)>,
443    result: &mut ScanResult,
444    context: &Path,
445) -> bool {
446    let total_files = audio_files.len();
447    if total_files == 0 {
448        return true;
449    }
450
451    // The scope's cache entries in one read, rather than a lookup per file.
452    let files_to_scan: Vec<PathBuf> = if opts.force {
453        std::mem::take(&mut audio_files)
454    } else {
455        let scan_cache = queries::load_scan_cache(&db.conn, scope).unwrap_or_default();
456        // One stat per file; in parallel, since on a network mount each is a round trip.
457        audio_files
458            .par_iter()
459            .filter(|file_path| {
460                let Ok(file_meta) = std::fs::metadata(file_path) else {
461                    return true;
462                };
463                let mtime = file_meta
464                    .modified()
465                    .ok()
466                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
467                    .map(|d| d.as_secs() as i64)
468                    .unwrap_or(0);
469                let size = file_meta.len() as i64;
470                let path_str = file_path.to_string_lossy();
471                match scan_cache.get(path_str.as_ref()) {
472                    Some(&(cached_mtime, cached_size)) => {
473                        mtime != cached_mtime || size != cached_size
474                    }
475                    None => true,
476                }
477            })
478            .cloned()
479            .collect()
480    };
481
482    result.skipped += total_files - files_to_scan.len();
483
484    // Tag reads and database writes run at the same time.
485    //
486    // Reading the whole library up front and writing it in one transaction
487    // blocks every other writer for the length of the scan and loses all of it
488    // on interrupt, so writes stay chunked. But doing that as read-chunk,
489    // write-chunk, read-chunk leaves the disk idle for every write and the CPU
490    // idle for every read — on a library of any size that is most of the run.
491    //
492    // Instead the reads stream: a worker pool walks every file and pushes
493    // results down a bounded channel while this thread batches them into
494    // transactions. The bound is what caps memory, in place of the chunking.
495    let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
496        CHUNK_SIZE.saturating_mul(2),
497    );
498    let cancel = opts.cancel.clone();
499    let reader = std::thread::Builder::new()
500        .name("koan-scan-read".into())
501        .spawn(move || {
502            // Stops at the first failed send (the consumer is gone) or cancel.
503            let _ = files_to_scan.par_iter().try_for_each(|file_path| {
504                if cancel
505                    .as_ref()
506                    .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
507                {
508                    return Err(());
509                }
510                send.send((
511                    file_path.clone(),
512                    isolate_read(file_path, metadata::read_metadata),
513                ))
514                .map_err(|_| ())
515            });
516        });
517    if let Err(e) = &reader {
518        log::error!("failed to spawn scan reader: {}", e);
519        result
520            .errors
521            .push((context.to_path_buf(), format!("scan error: {}", e)));
522        return false;
523    }
524
525    loop {
526        // Blocks until a full batch is ready or the readers have finished.
527        let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
528            recv.iter().take(CHUNK_SIZE).collect();
529        if batch.is_empty() {
530            break;
531        }
532        if opts
533            .cancel
534            .as_ref()
535            .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
536        {
537            log::info!("scan cancelled — keeping what was already committed");
538            result.cancelled = true;
539            break;
540        }
541
542        let tx = match crate::db::queries::write_transaction(&db.conn) {
543            Ok(tx) => tx,
544            Err(e) => {
545                log::error!("failed to begin scan transaction: {}", e);
546                result
547                    .errors
548                    .push((context.to_path_buf(), format!("db error: {}", e)));
549                return false;
550            }
551        };
552
553        let (mut added, mut updated) = (0usize, 0usize);
554        let mut arrived = Vec::new();
555        for (file_path, meta_result) in batch {
556            match meta_result {
557                Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
558                    Ok((track_id, is_new)) => {
559                        if is_new {
560                            added += 1;
561                            arrived.push(track_id);
562                        } else {
563                            updated += 1;
564                        }
565                        if let Some(cb) = &on_track {
566                            cb(ScanEvent {
567                                artist: &meta.artist,
568                                album: &meta.album,
569                                title: &meta.title,
570                                path: &file_path,
571                                is_new,
572                            });
573                        }
574                        if let Err(e) = queries::update_scan_cache(
575                            &tx,
576                            meta.path.as_deref().unwrap_or(""),
577                            meta.mtime.unwrap_or(0),
578                            meta.size_bytes.unwrap_or(0),
579                            track_id,
580                        ) {
581                            // Not fatal, but every future scan re-reads this file's tags.
582                            log::warn!("failed to cache {}: {}", file_path.display(), e);
583                        }
584                    }
585                    Err(e) => {
586                        result.errors.push((file_path, format!("db error: {}", e)));
587                    }
588                },
589                Err(e) => {
590                    result.errors.push((file_path, e));
591                }
592            }
593        }
594
595        match tx.commit() {
596            Ok(()) => {
597                result.added += added;
598                result.updated += updated;
599                result.arrived.extend(arrived);
600            }
601            Err(e) => {
602                log::error!("failed to commit scan transaction: {}", e);
603                result
604                    .errors
605                    .push((context.to_path_buf(), format!("db error: {}", e)));
606            }
607        }
608    }
609
610    // Dropped before the join: readers parked on a full channel only wake
611    // when their send fails.
612    drop(recv);
613    if let Ok(handle) = reader
614        && handle.join().is_err()
615    {
616        log::error!("scan reader thread panicked");
617    }
618    true
619}
620
621/// Remove the rows under `path` whose files are gone, in one transaction,
622/// after giving those that moved (to one of `arrived`) their tracks back.
623/// `walked` is every audio file the walk under `path` found, as stored: those
624/// are present without asking the filesystem again.
625fn remove_stale(
626    db: &Database,
627    path: &Path,
628    force_remove: bool,
629    arrived: &[i64],
630    walked: &HashSet<String>,
631    result: &mut ScanResult,
632) {
633    let tx = match crate::db::queries::write_transaction(&db.conn) {
634        Ok(tx) => tx,
635        Err(e) => {
636            log::error!("failed to begin stale-removal transaction: {}", e);
637            result
638                .errors
639                .push((path.to_path_buf(), format!("db error: {}", e)));
640            return;
641        }
642    };
643    let moved = match queries::adopt_moved_files(&tx, path, arrived, Some(walked)) {
644        Ok(moved) => moved,
645        Err(e) => {
646            log::error!("failed to match moved files: {}", e);
647            result.errors.push((path.to_path_buf(), e.to_string()));
648            return;
649        }
650    };
651    result.moved += moved;
652    match queries::remove_stale_tracks_walked(&tx, path, force_remove, Some(walked)) {
653        Ok(removed) => {
654            if let Err(e) = tx.commit() {
655                log::error!("failed to commit stale removals: {}", e);
656                result
657                    .errors
658                    .push((path.to_path_buf(), format!("db error: {}", e)));
659                return;
660            }
661            result.removed += removed.len();
662            result.removed_paths.extend(removed);
663        }
664        Err(e) => {
665            log::error!("failed to remove stale tracks: {}", e);
666            result.errors.push((path.to_path_buf(), e.to_string()));
667            // The brake refuses before removing anything; the moves found
668            // stand.
669            if matches!(e, crate::db::connection::DbError::UnsafeBulkDelete(_))
670                && let Err(e) = tx.commit()
671            {
672                log::error!("failed to commit moved files: {}", e);
673            }
674        }
675    }
676}
677
678/// Run a tag read, containing a panic from the parsers. Hostile input (a bogus
679/// ID3v2 frame size, a pathological MP4 atom tree) can panic inside lofty or
680/// symphonia; rayon re-raises that at `collect()`, which would otherwise abort
681/// the whole scan over one file and not even name it.
682fn isolate_read(
683    path: &Path,
684    read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
685) -> Result<TrackMeta, String> {
686    match catch_unwind(AssertUnwindSafe(|| read(path))) {
687        Ok(result) => result.map_err(|e| e.to_string()),
688        Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
689    }
690}
691
692/// What an import of specific files produced.
693#[derive(Debug, Default)]
694pub struct ImportResult {
695    /// Library rows for the imported files, in the order their paths were
696    /// walked. This is what a caller queues.
697    pub track_ids: Vec<i64>,
698    pub added: usize,
699    pub updated: usize,
700    pub errors: Vec<(PathBuf, String)>,
701}
702
703/// Index specific files into the library, wherever they live.
704///
705/// This is the drop-a-folder-on-the-queue path: the files named here are not
706/// under a configured library folder, and organize is what moves them there
707/// afterwards. Nothing is ever removed — the caller named these paths, so there
708/// is no directory listing to reconcile against and nothing to prune, which is
709/// what separates this from `scan_folder`.
710///
711/// Directories are walked recursively. Order is by path, so an album lands in
712/// the order its files are numbered.
713pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
714    let mut result = ImportResult::default();
715
716    let mut files: Vec<PathBuf> = Vec::new();
717    let mut scope = Vec::new();
718    let mut seen = std::collections::HashSet::new();
719    // Dropped paths are spelled by whoever dropped them, and Foundation spells
720    // accents differently from the disk. Walked children come from the
721    // directory itself and need nothing.
722    let mut spelling = super::spelling::Spelling::default();
723    for path in paths {
724        let path = spelling.on_disk(path);
725        let mut found: Vec<PathBuf> = walkdir::WalkDir::new(&path)
726            .follow_links(true)
727            .into_iter()
728            .filter_map(Result::ok)
729            .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
730            .filter(|e| {
731                let utf8 = e.path().to_str().is_some();
732                if !utf8 {
733                    log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
734                }
735                utf8
736            })
737            .map(|e| e.path().to_path_buf())
738            .collect();
739        found.sort();
740        scope.push(path);
741        // A drop can name both a folder and a file inside it.
742        files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
743    }
744
745    if files.is_empty() {
746        return result;
747    }
748
749    // Read and written as a scan reads and writes, so an unchanged file is not
750    // read again. Not in the lane: see `lane`.
751    let _importing = super::lane::importing();
752    let mut scanned = ScanResult::default();
753    index_files(
754        db,
755        files.clone(),
756        &scope,
757        &ScanOptions::default(),
758        None,
759        &mut scanned,
760        Path::new(""),
761    );
762    result.added = scanned.added;
763    result.updated = scanned.updated;
764    result.errors = scanned.errors;
765
766    // Every file's track, in the order walked: the ones just read and the
767    // ones the scan cache said were unchanged alike.
768    let mut track_of = match db
769        .conn
770        .prepare_cached("SELECT track_id FROM local_files WHERE path = ?1")
771    {
772        Ok(stmt) => stmt,
773        Err(e) => {
774            result
775                .errors
776                .push((PathBuf::new(), format!("db error: {e}")));
777            return result;
778        }
779    };
780    for file in &files {
781        if let Ok(id) = track_of.query_row([file.to_string_lossy()], |r| r.get::<_, i64>(0)) {
782            result.track_ids.push(id);
783        }
784    }
785    drop(track_of);
786
787    if result.added > 0 {
788        super::playlist_files::refresh_m3u(db);
789    }
790    result
791}
792
793/// Scan all configured library folders.
794pub fn full_scan(
795    db: &Database,
796    folders: &[PathBuf],
797    mut opts: ScanOptions,
798    on_track: Option<&dyn Fn(ScanEvent)>,
799) -> ScanResult {
800    let _turn = super::lane::enter(folders, &mut opts);
801    let existing: Vec<PathBuf> = folders
802        .iter()
803        .filter(|folder| {
804            let exists = folder.exists();
805            if !exists {
806                log::warn!("library folder does not exist: {}", folder.display());
807            }
808            exists
809        })
810        .cloned()
811        .collect();
812    let total = scan_folders_in_lane(db, &existing, &opts, None, on_track);
813    db.optimize();
814    total
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use crate::db::connection::Database;
821    use crate::db::queries;
822    use crate::test_utils;
823
824    fn test_db(dir: &Path) -> Database {
825        let db_path = dir.join("test.db");
826        Database::open(&db_path).unwrap()
827    }
828
829    #[test]
830    fn cancelling_a_scan_returns_with_readers_still_pending() {
831        let dir = tempfile::tempdir().unwrap();
832        let music_dir = dir.path().join("music");
833        std::fs::create_dir_all(&music_dir).unwrap();
834        // Far more than the channel holds (2 * CHUNK_SIZE), so readers are
835        // parked on a full channel when the consumer stops.
836        for i in 0..CHUNK_SIZE * 12 {
837            test_utils::generate_wav(&music_dir.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
838        }
839
840        let (done_tx, done_rx) = crossbeam_channel::bounded(1);
841        let root = dir.path().to_path_buf();
842        std::thread::spawn(move || {
843            let db = test_db(&root);
844            let cancel = Arc::new(AtomicBool::new(false));
845            let opts = ScanOptions {
846                cancel: Some(cancel.clone()),
847                ..Default::default()
848            };
849            let on_track = |_: ScanEvent| cancel.store(true, std::sync::atomic::Ordering::Relaxed);
850            let result = scan_folder(&db, &music_dir, opts, Some(&on_track));
851            done_tx.send(result).unwrap();
852        });
853
854        let result = done_rx
855            .recv_timeout(std::time::Duration::from_secs(30))
856            .expect("cancelled scan never returned");
857        assert!(result.cancelled);
858        assert!(result.added < CHUNK_SIZE * 12);
859    }
860
861    /// The folder watcher's scans carry no cancel flag of their own; the lane
862    /// gives them one, which the app's Cancel reaches.
863    #[test]
864    fn a_scan_started_without_a_cancel_flag_can_still_be_cancelled() {
865        let dir = tempfile::tempdir().unwrap();
866        let music = dir.path().join("music");
867        std::fs::create_dir_all(&music).unwrap();
868        for i in 0..CHUNK_SIZE * 6 {
869            test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
870        }
871        let db = test_db(dir.path());
872        let on_track = |_: ScanEvent| super::super::lane::cancel_all();
873        let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
874        assert!(result.cancelled);
875        assert!(result.added < CHUNK_SIZE * 6);
876    }
877
878    /// Forgetting a folder stops a scan of it, and only of it.
879    #[test]
880    fn a_scan_is_cancelled_by_forgetting_its_folder_only() {
881        let dir = tempfile::tempdir().unwrap();
882        let music = dir.path().join("music");
883        std::fs::create_dir_all(&music).unwrap();
884        for i in 0..CHUNK_SIZE * 6 {
885            test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
886        }
887        let elsewhere = dir.path().join("elsewhere");
888        let db = test_db(dir.path());
889
890        let on_track = |_: ScanEvent| super::super::lane::cancel_under(&elsewhere);
891        let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
892        assert!(!result.cancelled);
893        assert_eq!(result.added, CHUNK_SIZE * 6);
894
895        let album = music.join("Album");
896        std::fs::create_dir_all(&album).unwrap();
897        for i in 0..CHUNK_SIZE * 6 {
898            test_utils::generate_wav(&album.join(format!("{i:03}.wav")), 8000, 1, 0.02, 16);
899        }
900        let on_track = |_: ScanEvent| super::super::lane::cancel_under(&album);
901        let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
902        assert!(result.cancelled, "a scan of the folder it is in");
903    }
904
905    /// Scans take turns: one waits while another holds the lane, and starts
906    /// when it is let go.
907    #[test]
908    fn scans_wait_their_turn() {
909        let dir = tempfile::tempdir().unwrap();
910        let music = dir.path().join("music");
911        std::fs::create_dir_all(&music).unwrap();
912        test_utils::generate_wav(&music.join("a.wav"), 8000, 1, 0.1, 16);
913        let db_root = dir.path().to_path_buf();
914
915        let held = super::super::lane::wait();
916        let (done_tx, done_rx) = crossbeam_channel::bounded(1);
917        std::thread::spawn(move || {
918            let db = test_db(&db_root);
919            done_tx
920                .send(scan_folder(&db, &music, ScanOptions::default(), None).added)
921                .unwrap();
922        });
923        assert!(
924            done_rx
925                .recv_timeout(std::time::Duration::from_millis(300))
926                .is_err(),
927            "ran while the lane was held"
928        );
929        drop(held);
930        let added = done_rx
931            .recv_timeout(std::time::Duration::from_secs(30))
932            .expect("never ran once the lane was free");
933        assert_eq!(added, 1);
934    }
935
936    /// A drop is for playing now: it does not queue behind a scan, which on a
937    /// large library can take minutes.
938    #[test]
939    fn an_import_does_not_wait_for_a_scan() {
940        let dir = tempfile::tempdir().unwrap();
941        let rip = dir.path().join("rip");
942        std::fs::create_dir_all(&rip).unwrap();
943        test_utils::generate_wav(&rip.join("a.wav"), 8000, 1, 0.1, 16);
944        let db_root = dir.path().to_path_buf();
945
946        let mut opts = ScanOptions::default();
947        let held = super::super::lane::enter(&[dir.path().join("library")], &mut opts);
948        let (done_tx, done_rx) = crossbeam_channel::bounded(1);
949        std::thread::spawn(move || {
950            let db = test_db(&db_root);
951            done_tx
952                .send(
953                    import_paths(&db, std::slice::from_ref(&rip))
954                        .track_ids
955                        .len(),
956                )
957                .unwrap();
958        });
959        let imported = done_rx
960            .recv_timeout(std::time::Duration::from_secs(30))
961            .expect("waited for the scan");
962        assert_eq!(imported, 1);
963        drop(held);
964    }
965
966    #[test]
967    fn scan_folder_indexes_new_files() {
968        let dir = tempfile::tempdir().unwrap();
969        let music_dir = dir.path().join("music");
970        std::fs::create_dir_all(&music_dir).unwrap();
971
972        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
973        let wav_path = music_dir.join("silence.wav");
974        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
975
976        let db = test_db(dir.path());
977        let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
978
979        assert_eq!(result.added, 1, "expected 1 track added");
980        assert_eq!(result.skipped, 0);
981        assert_eq!(result.removed, 0);
982        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
983
984        // Verify the track exists in the DB.
985        let stats = queries::library_stats(&db.conn).unwrap();
986        assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
987    }
988
989    #[test]
990    fn a_renamed_file_keeps_its_track() {
991        let dir = tempfile::tempdir().unwrap();
992        let music = dir.path().join("music");
993        let album = music.join("Squire Of Gothos").join("Album");
994        std::fs::create_dir_all(&album).unwrap();
995        for i in 1..=3 {
996            test_utils::generate_wav(
997                &album.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
998                44100,
999                1,
1000                0.5 + i as f32 * 0.1,
1001                16,
1002            );
1003        }
1004        let db = test_db(dir.path());
1005        scan_folder(&db, &music, ScanOptions::default(), None);
1006        let before = tracks_with_uids(&db);
1007        let first = before[0].0;
1008        queries::record_play(&db.conn, queries::LOCAL_USER, first, Some(1000)).unwrap();
1009        queries::add_favourite(&db.conn, queries::LOCAL_USER, first).unwrap();
1010
1011        // What a retag that re-files does: the artist folder and file names
1012        // change case.
1013        let moved = music.join("The Squire of Gothos").join("Album");
1014        std::fs::create_dir_all(moved.parent().unwrap()).unwrap();
1015        std::fs::rename(&album, &moved).unwrap();
1016        std::fs::remove_dir(music.join("Squire Of Gothos")).unwrap();
1017        for i in 1..=3 {
1018            std::fs::rename(
1019                moved.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
1020                moved.join(format!("0{i}. The Squire of Gothos - T{i}.wav")),
1021            )
1022            .unwrap();
1023        }
1024        let result = scan_folder(&db, &music, ScanOptions::default(), None);
1025
1026        assert_eq!(
1027            (result.added, result.removed, result.moved),
1028            (0, 0, 3),
1029            "{:?}",
1030            result.errors
1031        );
1032        let paths = track_paths(&db);
1033        assert_eq!(paths.len(), 3, "{paths:#?}");
1034        assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1035        let after = tracks_with_uids(&db);
1036        assert_eq!(
1037            before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1038            after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1039            "same rows, same uids"
1040        );
1041        let kept: (i64, i64) = db
1042            .conn
1043            .query_row(
1044                "SELECT (SELECT COUNT(*) FROM play_history WHERE track_id = ?1),
1045                        (SELECT COUNT(*) FROM favourites WHERE track_id = ?1)",
1046                [first],
1047                |r| Ok((r.get(0)?, r.get(1)?)),
1048            )
1049            .unwrap();
1050        assert_eq!(kept, (1, 1), "history and favourite kept");
1051    }
1052
1053    /// Moved from one library folder to another: every folder is indexed
1054    /// before any is pruned, so the old path is not missed first.
1055    /// A library that already holds one file twice, under the spelling
1056    /// organize once stored and the directory's own, comes back to one track:
1057    /// the older, with its history. Only where the filesystem opens both
1058    /// spellings, which is the only place organize could have left them.
1059    #[cfg(target_os = "macos")]
1060    #[test]
1061    fn a_file_stored_under_two_spellings_is_folded_into_one_track() {
1062        use unicode_normalization::UnicodeNormalization;
1063        let dir = tempfile::tempdir().unwrap();
1064        let music = dir.path().join("music");
1065        let nfd: String = "Björk".nfd().collect();
1066        let nfc: String = "Björk".nfc().collect();
1067        std::fs::create_dir_all(music.join(&nfd)).unwrap();
1068        test_utils::generate_wav(&music.join(&nfd).join("Jóga.wav"), 44100, 1, 0.5, 16);
1069        let on_disk = music
1070            .join(&nfd)
1071            .join("Jóga.wav")
1072            .to_string_lossy()
1073            .into_owned();
1074        let organized = music
1075            .join(&nfc)
1076            .join("Jóga.wav")
1077            .to_string_lossy()
1078            .into_owned();
1079        let db = test_db(dir.path());
1080        scan_folder(&db, &music, ScanOptions::default(), None);
1081        let original = tracks_with_uids(&db)[0].0;
1082        queries::record_play(&db.conn, queries::LOCAL_USER, original, Some(1000)).unwrap();
1083        // What organize stored before it took the directory's spelling: every
1084        // path-keyed row, as `rewrite_path_references` writes them.
1085        queries::sources::rename_file(&db.conn, &on_disk, &organized).unwrap();
1086        for table in ["tracks", "scan_cache"] {
1087            db.conn
1088                .execute(
1089                    &format!("UPDATE {table} SET path = ?1 WHERE path = ?2"),
1090                    [&organized, &on_disk],
1091                )
1092                .unwrap();
1093        }
1094        // The scan that made the second row.
1095        scan_folder(&db, &music, ScanOptions::default(), None);
1096        scan_folder(&db, &music, ScanOptions::default(), None);
1097
1098        let tracks = tracks_with_uids(&db);
1099        assert_eq!(tracks.len(), 1, "{tracks:?}");
1100        assert_eq!(tracks[0].0, original);
1101        assert_eq!(track_paths(&db), vec![on_disk]);
1102        let plays: i64 = db
1103            .conn
1104            .query_row(
1105                "SELECT COUNT(*) FROM play_history WHERE track_id = ?1",
1106                [original],
1107                |r| r.get(0),
1108            )
1109            .unwrap();
1110        assert_eq!(plays, 1);
1111    }
1112
1113    /// A library folder configured in another case than the disk's, whose
1114    /// rows an earlier build stored under the configured spelling, keeps one
1115    /// track per file once scans walk the disk's: the rows move across, and a
1116    /// file already indexed under both is folded into its original track.
1117    #[cfg(target_os = "macos")]
1118    #[test]
1119    fn rows_stored_under_a_library_folder_in_another_case_move_to_the_disks() {
1120        let dir = tempfile::tempdir().unwrap();
1121        let music = dir.path().join("Music");
1122        let given = dir.path().join("music");
1123        std::fs::create_dir_all(music.join("Artist")).unwrap();
1124        if !given.exists() {
1125            return;
1126        }
1127        for name in ["a.wav", "b.wav"] {
1128            test_utils::generate_wav(&music.join("Artist").join(name), 44100, 1, 0.2, 16);
1129        }
1130        let db = test_db(dir.path());
1131        scan_folder(&db, &music, ScanOptions::default(), None);
1132        let original: Vec<i64> = tracks_with_uids(&db).iter().map(|t| t.0).collect();
1133        // As an earlier build stored them, scanning the folder as configured.
1134        let (from, to) = (
1135            format!("{}/", music.display()),
1136            format!("{}/", given.display()),
1137        );
1138        for (table, column) in [
1139            ("tracks", "path"),
1140            ("local_files", "path"),
1141            ("scan_cache", "path"),
1142        ] {
1143            db.conn
1144                .execute(
1145                    &format!("UPDATE {table} SET {column} = replace({column}, ?1, ?2)"),
1146                    [&from, &to],
1147                )
1148                .unwrap();
1149        }
1150        // And one file indexed a second time under the disk's spelling.
1151        let twin = music.join("Artist/b.wav");
1152        import_paths(&db, std::slice::from_ref(&twin));
1153        assert_eq!(tracks_with_uids(&db).len(), 3);
1154
1155        scan_folder(&db, &given, ScanOptions::default(), None);
1156
1157        let tracks = tracks_with_uids(&db);
1158        assert_eq!(
1159            tracks.iter().map(|t| t.0).collect::<Vec<_>>(),
1160            original,
1161            "{tracks:?}"
1162        );
1163        assert!(
1164            track_paths(&db).iter().all(|p| p.starts_with(&from)),
1165            "{:?}",
1166            track_paths(&db)
1167        );
1168        let stale: i64 = db
1169            .conn
1170            .query_row(
1171                "SELECT (SELECT COUNT(*) FROM local_files WHERE substr(path, 1, length(?1)) = ?1)
1172                      + (SELECT COUNT(*) FROM scan_cache WHERE substr(path, 1, length(?1)) = ?1)",
1173                [&to],
1174                |r| r.get(0),
1175            )
1176            .unwrap();
1177        assert_eq!(stale, 0);
1178    }
1179
1180    /// The rarer leftovers of a library scanned under both spellings: a file
1181    /// two server tracks claimed, which cannot be folded, goes to the track
1182    /// under the disk's spelling; a playlist file read twice keeps its first
1183    /// playlist.
1184    #[cfg(target_os = "macos")]
1185    #[test]
1186    fn respelling_a_folder_leaves_nothing_under_the_old_spelling() {
1187        let dir = tempfile::tempdir().unwrap();
1188        let music = dir.path().join("Music");
1189        let given = dir.path().join("music");
1190        std::fs::create_dir_all(&music).unwrap();
1191        if !given.exists() {
1192            return;
1193        }
1194        let file = music.join("b.wav");
1195        test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1196        let db = test_db(dir.path());
1197        let mut tracks = Vec::new();
1198        for (root, remote) in [(&given, "sub-1"), (&music, "sub-2")] {
1199            let mut meta = metadata::read_metadata(&file).unwrap();
1200            meta.path = Some(root.join("b.wav").to_string_lossy().into_owned());
1201            meta.remote_id = Some(remote.into());
1202            tracks.push(queries::upsert_track(&db.conn, &meta).unwrap());
1203        }
1204        let local_files = |db: &Database| -> Vec<(i64, String)> {
1205            db.conn
1206                .prepare("SELECT track_id, path FROM local_files ORDER BY path")
1207                .unwrap()
1208                .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1209                .unwrap()
1210                .collect::<Result<_, _>>()
1211                .unwrap()
1212        };
1213        assert_eq!(local_files(&db).len(), 2, "two tracks, one file each");
1214        let playlists: Vec<i64> = [&given, &music]
1215            .iter()
1216            .map(|root| {
1217                let id =
1218                    queries::create_playlist(&db.conn, queries::LOCAL_USER, "list", None).unwrap();
1219                db.conn
1220                    .execute(
1221                        "UPDATE playlists SET source_path = ?1 WHERE id = ?2",
1222                        rusqlite::params![root.join("list.nsp").to_string_lossy(), id],
1223                    )
1224                    .unwrap();
1225                id
1226            })
1227            .collect();
1228
1229        queries::sources::respell_folder(&db.conn, &given, &music).unwrap();
1230
1231        assert_eq!(
1232            local_files(&db),
1233            vec![(tracks[1], file.to_string_lossy().into_owned())]
1234        );
1235        let remaining: i64 = db
1236            .conn
1237            .query_row(
1238                "SELECT COUNT(*) FROM tracks WHERE id IN (?1, ?2)",
1239                [tracks[0], tracks[1]],
1240                |r| r.get(0),
1241            )
1242            .unwrap();
1243        assert_eq!(remaining, 2, "both stay as the server's");
1244        let kept: Vec<(i64, String)> = db
1245            .conn
1246            .prepare("SELECT id, source_path FROM playlists WHERE source_path IS NOT NULL")
1247            .unwrap()
1248            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1249            .unwrap()
1250            .collect::<Result<_, _>>()
1251            .unwrap();
1252        assert_eq!(
1253            kept,
1254            vec![(
1255                playlists[0],
1256                music.join("list.nsp").to_string_lossy().into_owned()
1257            )]
1258        );
1259    }
1260
1261    #[test]
1262    fn a_file_moved_between_library_folders_keeps_its_track() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let (one, two) = (dir.path().join("one"), dir.path().join("two"));
1265        std::fs::create_dir_all(one.join("Album")).unwrap();
1266        std::fs::create_dir_all(&two).unwrap();
1267        test_utils::generate_wav(&one.join("Album/a.wav"), 44100, 1, 0.2, 16);
1268        test_utils::generate_wav(&one.join("b.wav"), 44100, 1, 0.3, 16);
1269        test_utils::generate_wav(&two.join("c.wav"), 44100, 1, 0.4, 16);
1270        let db = test_db(dir.path());
1271        let folders = [one.clone(), two.clone()];
1272        scan_folders(&db, &folders, ScanOptions::default(), None, None);
1273        let before = tracks_with_uids(&db);
1274
1275        std::fs::rename(one.join("Album"), two.join("Album")).unwrap();
1276        let r = scan_folders(&db, &folders, ScanOptions::default(), None, None);
1277        assert_eq!((r.added, r.removed, r.moved), (0, 0, 1), "{:?}", r.errors);
1278        let after = tracks_with_uids(&db);
1279        assert_eq!(
1280            before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1281            after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>()
1282        );
1283        assert!(after.iter().any(|t| t.2.ends_with("two/Album/a.wav")));
1284    }
1285
1286    /// Track ids, uids and paths, by id.
1287    fn tracks_with_uids(db: &Database) -> Vec<(i64, String, String)> {
1288        db.conn
1289            .prepare("SELECT id, uid, path FROM tracks ORDER BY id")
1290            .unwrap()
1291            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1292            .unwrap()
1293            .collect::<Result<_, _>>()
1294            .unwrap()
1295    }
1296
1297    fn track_paths(db: &Database) -> Vec<String> {
1298        db.conn
1299            .prepare("SELECT path FROM tracks ORDER BY path")
1300            .unwrap()
1301            .query_map([], |r| r.get(0))
1302            .unwrap()
1303            .collect::<Result<_, _>>()
1304            .unwrap()
1305    }
1306
1307    /// A track also on the server outlives its folder being forgotten, as the
1308    /// server's copy. Added back, the folder's file must be read again.
1309    #[test]
1310    fn a_forgotten_folder_added_back_is_read_again() {
1311        let dir = tempfile::tempdir().unwrap();
1312        let music = dir.path().join("music");
1313        std::fs::create_dir_all(&music).unwrap();
1314        let file = music.join("kept.wav");
1315        test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1316        let db = test_db(dir.path());
1317        let mut both = metadata::read_metadata(&file).unwrap();
1318        both.remote_id = Some("sub-1".into());
1319        let track = queries::upsert_track(&db.conn, &both).unwrap();
1320        let local = |db: &Database| -> Option<String> {
1321            db.conn
1322                .query_row("SELECT path FROM tracks WHERE id = ?1", [track], |r| {
1323                    r.get(0)
1324                })
1325                .unwrap()
1326        };
1327        scan_folder(&db, &music, ScanOptions::default(), None);
1328
1329        assert_eq!(crate::helpers::forget_folder(&db, &music).unwrap(), 1);
1330        assert_eq!(local(&db), None, "kept as the server's copy");
1331
1332        let again = scan_folder(&db, &music, ScanOptions::default(), None);
1333        assert_eq!(again.skipped, 0, "{:?}", again.errors);
1334        assert_eq!(local(&db), Some(file.to_string_lossy().into_owned()));
1335    }
1336
1337    #[test]
1338    fn scans_skip_what_the_watcher_ignores() {
1339        let dir = tempfile::tempdir().unwrap();
1340        let music = dir.path().join("music");
1341        let album = music.join("Artist/Album");
1342        let dotted = music.join("Britney Spears/...Baby One More Time");
1343        for sub in [
1344            album.join(".stversions"),
1345            album.join("Disc 2.part"),
1346            dotted.clone(),
1347        ] {
1348            std::fs::create_dir_all(sub).unwrap();
1349        }
1350        let wav = |p: PathBuf| test_utils::generate_wav(&p, 8000, 1, 0.1, 16);
1351        wav(album.join("01.wav"));
1352        wav(dotted.join("01.wav"));
1353        wav(album.join(".stversions/01~20261006-120000.wav"));
1354        wav(album.join("._01.wav"));
1355        wav(album.join("Disc 2.part/02.wav"));
1356        wav(album.join("~syncthing~03.wav"));
1357        let db = test_db(dir.path());
1358
1359        let full = scan_folder(&db, &music, ScanOptions::default(), None);
1360        assert_eq!(full.added, 2, "{:?}", full.errors);
1361        let dirs = scan_dirs(
1362            &db,
1363            std::slice::from_ref(&music),
1364            std::slice::from_ref(&album),
1365            ScanOptions::default(),
1366            None,
1367        );
1368        assert_eq!(dirs.added, 0, "{:?}", dirs.errors);
1369        assert_eq!(
1370            track_paths(&db),
1371            [album.join("01.wav"), dotted.join("01.wav")].map(|p| p.to_string_lossy().into_owned())
1372        );
1373    }
1374
1375    #[test]
1376    fn minimal_dirs_keeps_only_the_outermost() {
1377        let dirs = [
1378            "/m/A/Album 2",
1379            "/m/A/Album",
1380            "/m/A/Album/CD1",
1381            "/m/A/Album",
1382            "/m/AB",
1383            "/m/B/Album/CD2",
1384        ]
1385        .map(PathBuf::from)
1386        .to_vec();
1387        assert_eq!(
1388            minimal_dirs(dirs),
1389            ["/m/A/Album", "/m/A/Album 2", "/m/AB", "/m/B/Album/CD2"].map(PathBuf::from)
1390        );
1391        assert_eq!(
1392            minimal_dirs(["/m/A/x", "/m/A"].map(PathBuf::from).to_vec()),
1393            [PathBuf::from("/m/A")]
1394        );
1395    }
1396
1397    #[test]
1398    fn scan_dirs_covers_only_the_directories_named() {
1399        let dir = tempfile::tempdir().unwrap();
1400        let music = dir.path().join("music");
1401        let (old, new) = (music.join("Old"), music.join("New"));
1402        std::fs::create_dir_all(&old).unwrap();
1403        std::fs::create_dir_all(&new).unwrap();
1404        test_utils::generate_wav(&old.join("a.wav"), 44100, 1, 0.2, 16);
1405
1406        let db = test_db(dir.path());
1407        scan_folder(&db, &music, ScanOptions::default(), None);
1408        test_utils::generate_wav(&new.join("b.wav"), 44100, 1, 0.2, 16);
1409        // Not named, so not seen: this scan is not a walk of the library.
1410        test_utils::generate_wav(&old.join("c.wav"), 44100, 1, 0.3, 16);
1411
1412        let r = scan_dirs(
1413            &db,
1414            std::slice::from_ref(&music),
1415            std::slice::from_ref(&new),
1416            ScanOptions::default(),
1417            None,
1418        );
1419        assert_eq!((r.added, r.skipped, r.removed), (1, 0, 0), "{:?}", r.errors);
1420        assert_eq!(track_paths(&db).len(), 2);
1421    }
1422
1423    #[test]
1424    fn rescanning_a_directory_tells_the_apps_its_art_may_have_changed() {
1425        let dir = tempfile::tempdir().unwrap();
1426        let music = dir.path().join("music");
1427        let (album, other) = (music.join("Album"), music.join("Other"));
1428        std::fs::create_dir_all(album.join("CD1")).unwrap();
1429        std::fs::create_dir_all(&other).unwrap();
1430        test_utils::generate_wav(&album.join("CD1/a.wav"), 44100, 1, 0.2, 16);
1431        test_utils::generate_wav(&other.join("b.wav"), 44100, 1, 0.2, 16);
1432        let db = test_db(dir.path());
1433        scan_folder(&db, &music, ScanOptions::default(), None);
1434        let evicted = |db: &Database| -> Vec<(String, i64)> {
1435            db.conn
1436                .prepare("SELECT kind, id FROM art_evictions ORDER BY seq")
1437                .unwrap()
1438                .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1439                .unwrap()
1440                .collect::<Result<_, _>>()
1441                .unwrap()
1442        };
1443        assert!(evicted(&db).is_empty());
1444
1445        // The watcher names the album's folder when a cover lands in it.
1446        std::fs::write(album.join("cover.jpg"), b"").unwrap();
1447        scan_dirs(
1448            &db,
1449            std::slice::from_ref(&music),
1450            std::slice::from_ref(&album),
1451            ScanOptions::default(),
1452            None,
1453        );
1454        let (album_id, track_id): (i64, i64) = db
1455            .conn
1456            .query_row(
1457                "SELECT album_id, id FROM tracks WHERE path LIKE '%/CD1/a.wav'",
1458                [],
1459                |r| Ok((r.get(0)?, r.get(1)?)),
1460            )
1461            .unwrap();
1462        assert_eq!(
1463            evicted(&db),
1464            vec![("album".into(), album_id), ("track".into(), track_id)],
1465            "only the records under the folder rescanned"
1466        );
1467    }
1468
1469    #[test]
1470    fn scan_dirs_removes_what_left_a_directory_and_a_directory_that_left() {
1471        let dir = tempfile::tempdir().unwrap();
1472        let music = dir.path().join("music");
1473        let (kept, gone) = (music.join("Artist/Kept"), music.join("Artist/Gone"));
1474        std::fs::create_dir_all(&kept).unwrap();
1475        std::fs::create_dir_all(&gone).unwrap();
1476        for i in 0..3 {
1477            test_utils::generate_wav(&kept.join(format!("{i}.wav")), 44100, 1, 0.2, 16);
1478            test_utils::generate_wav(&gone.join(format!("{i}.wav")), 44100, 1, 0.3, 16);
1479        }
1480        let db = test_db(dir.path());
1481        assert_eq!(
1482            scan_folder(&db, &music, ScanOptions::default(), None).added,
1483            6
1484        );
1485
1486        std::fs::remove_file(kept.join("0.wav")).unwrap();
1487        std::fs::remove_dir_all(&gone).unwrap();
1488        let r = scan_dirs(
1489            &db,
1490            std::slice::from_ref(&music),
1491            &[kept.clone(), gone.clone()],
1492            ScanOptions::default(),
1493            None,
1494        );
1495
1496        assert_eq!((r.removed, r.skipped), (4, 2), "{:?}", r.errors);
1497        let paths = track_paths(&db);
1498        assert_eq!(paths.len(), 2, "{paths:#?}");
1499        assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1500    }
1501
1502    /// An unmounted volume leaves an empty mount point, under which every
1503    /// directory reads as deleted.
1504    #[test]
1505    fn scan_dirs_removes_nothing_under_an_empty_library_folder() {
1506        let dir = tempfile::tempdir().unwrap();
1507        let music = dir.path().join("music");
1508        let album = music.join("Album");
1509        std::fs::create_dir_all(&album).unwrap();
1510        test_utils::generate_wav(&album.join("a.wav"), 44100, 1, 0.2, 16);
1511        let db = test_db(dir.path());
1512        scan_folder(&db, &music, ScanOptions::default(), None);
1513
1514        std::fs::remove_dir_all(&album).unwrap();
1515        let r = scan_dirs(
1516            &db,
1517            std::slice::from_ref(&music),
1518            std::slice::from_ref(&album),
1519            ScanOptions::default(),
1520            None,
1521        );
1522        assert_eq!(r.removed, 0);
1523        assert_eq!(track_paths(&db).len(), 1);
1524    }
1525
1526    /// A share linked into the library and gone away leaves a dangling
1527    /// symlink, through which every file reads as deleted. Neither a full
1528    /// scan nor the watcher's forced rescan may take that as a deletion.
1529    #[cfg(unix)]
1530    #[test]
1531    fn a_dangling_symlink_is_not_a_deletion() {
1532        let dir = tempfile::tempdir().unwrap();
1533        let music = dir.path().join("music");
1534        let local = music.join("Local");
1535        let share = dir.path().join("share");
1536        std::fs::create_dir_all(&local).unwrap();
1537        std::fs::create_dir_all(share.join("Album")).unwrap();
1538        test_utils::generate_wav(&local.join("a.wav"), 44100, 1, 0.2, 16);
1539        test_utils::generate_wav(&share.join("Album/b.wav"), 44100, 1, 0.2, 16);
1540        std::os::unix::fs::symlink(&share, music.join("nas")).unwrap();
1541        let db = test_db(dir.path());
1542        assert_eq!(
1543            scan_folder(&db, &music, ScanOptions::default(), None).added,
1544            2
1545        );
1546
1547        std::fs::remove_dir_all(&share).unwrap();
1548        let full = scan_folder(&db, &music, ScanOptions::default(), None);
1549        assert_eq!(full.removed, 0, "{:?}", full.removed_paths);
1550        let watched = scan_dirs(
1551            &db,
1552            std::slice::from_ref(&music),
1553            &[music.join("nas/Album")],
1554            ScanOptions::default(),
1555            None,
1556        );
1557        assert_eq!(watched.removed, 0, "{:?}", watched.removed_paths);
1558        assert_eq!(track_paths(&db).len(), 2);
1559
1560        // The link itself removed is a deletion like any other.
1561        std::fs::remove_file(music.join("nas")).unwrap();
1562        let removed = scan_folder(&db, &music, ScanOptions::default(), None);
1563        assert_eq!(removed.removed, 1);
1564    }
1565
1566    /// A name that is not UTF-8 would be stored under a different one, which
1567    /// no stat finds: the same scan would add it and remove it.
1568    #[cfg(target_os = "linux")]
1569    #[test]
1570    fn a_path_that_is_not_utf8_is_skipped() {
1571        use std::os::unix::ffi::OsStrExt;
1572        let dir = tempfile::tempdir().unwrap();
1573        let music = dir.path().join("music");
1574        std::fs::create_dir_all(&music).unwrap();
1575        test_utils::generate_wav(&music.join("a.wav"), 44100, 1, 0.2, 16);
1576        let odd = music.join(std::ffi::OsStr::from_bytes(b"caf\xe9.wav"));
1577        test_utils::generate_wav(&odd, 44100, 1, 0.2, 16);
1578        let db = test_db(dir.path());
1579
1580        let r = scan_folder(&db, &music, ScanOptions::default(), None);
1581        assert_eq!((r.added, r.removed), (1, 0));
1582        assert_eq!(track_paths(&db).len(), 1);
1583    }
1584
1585    #[test]
1586    fn scan_dirs_ignores_directories_outside_the_library() {
1587        let dir = tempfile::tempdir().unwrap();
1588        let music = dir.path().join("music");
1589        let elsewhere = dir.path().join("elsewhere");
1590        std::fs::create_dir_all(&music).unwrap();
1591        std::fs::create_dir_all(&elsewhere).unwrap();
1592        test_utils::generate_wav(&elsewhere.join("a.wav"), 44100, 1, 0.2, 16);
1593        let db = test_db(dir.path());
1594
1595        let r = scan_dirs(
1596            &db,
1597            std::slice::from_ref(&music),
1598            std::slice::from_ref(&elsewhere),
1599            ScanOptions::default(),
1600            None,
1601        );
1602        assert_eq!(r.added, 0);
1603        assert!(track_paths(&db).is_empty());
1604    }
1605
1606    #[test]
1607    fn import_paths_indexes_files_where_they_lie() {
1608        let dir = tempfile::tempdir().unwrap();
1609        // Deliberately nothing to do with a library folder — this is the
1610        // drag-a-rip-onto-the-queue case.
1611        let drop = dir.path().join("Downloads/rip");
1612        std::fs::create_dir_all(&drop).unwrap();
1613        test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
1614        test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
1615        std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
1616
1617        let db = test_db(dir.path());
1618        let result = import_paths(&db, std::slice::from_ref(&drop));
1619
1620        assert_eq!(result.added, 2);
1621        assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
1622        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1623
1624        // The rows point at where the files still are; organize is what moves them.
1625        for id in &result.track_ids {
1626            let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
1627            assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
1628        }
1629    }
1630
1631    /// Dropping the same rip twice queues it again without duplicating rows.
1632    #[test]
1633    fn import_paths_is_idempotent() {
1634        let dir = tempfile::tempdir().unwrap();
1635        let drop = dir.path().join("rip");
1636        std::fs::create_dir_all(&drop).unwrap();
1637        test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
1638
1639        let db = test_db(dir.path());
1640        let first = import_paths(&db, std::slice::from_ref(&drop));
1641        let second = import_paths(&db, std::slice::from_ref(&drop));
1642
1643        assert_eq!(first.added, 1);
1644        // Through the scan cache: unchanged, so not read again, and still queued.
1645        assert_eq!((second.added, second.updated), (0, 0));
1646        assert_eq!(first.track_ids, second.track_ids);
1647        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1648    }
1649
1650    /// A drop can name a folder and a file inside it; the file is imported once.
1651    #[test]
1652    fn import_paths_deduplicates_overlapping_selections() {
1653        let dir = tempfile::tempdir().unwrap();
1654        let drop = dir.path().join("rip");
1655        std::fs::create_dir_all(&drop).unwrap();
1656        let track = drop.join("a.wav");
1657        test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
1658
1659        let db = test_db(dir.path());
1660        let result = import_paths(&db, &[drop.clone(), track.clone()]);
1661
1662        assert_eq!(result.track_ids.len(), 1);
1663    }
1664
1665    #[test]
1666    fn scan_folder_skips_unchanged_files() {
1667        let dir = tempfile::tempdir().unwrap();
1668        let music_dir = dir.path().join("music");
1669        std::fs::create_dir_all(&music_dir).unwrap();
1670
1671        let wav_path = music_dir.join("unchanged.wav");
1672        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1673
1674        let db = test_db(dir.path());
1675
1676        // First scan: adds the file.
1677        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1678        assert_eq!(r1.added, 1);
1679
1680        // Second scan: file unchanged, should be skipped.
1681        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1682        assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
1683        assert_eq!(r2.added, 0, "no new files should be added");
1684    }
1685
1686    #[test]
1687    fn scan_folder_removes_deleted_tracks() {
1688        let dir = tempfile::tempdir().unwrap();
1689        let music_dir = dir.path().join("music");
1690        std::fs::create_dir_all(&music_dir).unwrap();
1691
1692        let wav_path = music_dir.join("ephemeral.wav");
1693        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1694        test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
1695
1696        let db = test_db(dir.path());
1697
1698        // First scan: adds both files.
1699        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1700        assert_eq!(r1.added, 2);
1701
1702        // Delete one of them.
1703        std::fs::remove_file(&wav_path).unwrap();
1704
1705        // Second scan: should detect removal.
1706        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1707        assert_eq!(
1708            r2.removed, 1,
1709            "expected 1 track removed after file deletion"
1710        );
1711
1712        let stats = queries::library_stats(&db.conn).unwrap();
1713        assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
1714    }
1715
1716    #[test]
1717    fn empty_folder_does_not_wipe_the_library() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let music_dir = dir.path().join("music");
1720        std::fs::create_dir_all(&music_dir).unwrap();
1721        test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
1722        test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
1723
1724        let db = test_db(dir.path());
1725        assert_eq!(
1726            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1727            2
1728        );
1729
1730        // The folder is still there but yields nothing — an unmounted NAS, a
1731        // detached volume, a Docker volume that failed to attach.
1732        std::fs::remove_file(music_dir.join("a.wav")).unwrap();
1733        std::fs::remove_file(music_dir.join("b.wav")).unwrap();
1734
1735        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1736        assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
1737        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1738    }
1739
1740    #[cfg(unix)]
1741    #[test]
1742    fn unreadable_folder_is_not_a_deletion() {
1743        use std::os::unix::fs::PermissionsExt;
1744
1745        let dir = tempfile::tempdir().unwrap();
1746        let music_dir = dir.path().join("music");
1747        let locked_dir = music_dir.join("locked");
1748        std::fs::create_dir_all(&locked_dir).unwrap();
1749        test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
1750        let locked_file = locked_dir.join("locked.wav");
1751        test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
1752
1753        let db = test_db(dir.path());
1754        assert_eq!(
1755            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1756            2
1757        );
1758
1759        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
1760        if locked_file.try_exists().is_ok() {
1761            // Running as root — the permission bits mean nothing here.
1762            std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1763            return;
1764        }
1765
1766        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1767        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1768
1769        assert!(
1770            r.unreadable >= 1,
1771            "the unreadable subtree should be counted"
1772        );
1773        assert_eq!(r.removed, 0, "an IO error is not a deletion");
1774        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1775    }
1776
1777    #[test]
1778    fn interrupted_scan_keeps_committed_chunks_and_resumes() {
1779        let dir = tempfile::tempdir().unwrap();
1780        let music_dir = dir.path().join("music");
1781        std::fs::create_dir_all(&music_dir).unwrap();
1782        for i in 0..6 {
1783            test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
1784        }
1785
1786        let db = test_db(dir.path());
1787
1788        // Abort partway through the second chunk, the way Ctrl-C would.
1789        let seen = std::cell::Cell::new(0usize);
1790        let abort = |_: ScanEvent| {
1791            seen.set(seen.get() + 1);
1792            assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
1793        };
1794        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1795            scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
1796        }));
1797        assert!(panicked.is_err());
1798
1799        // The first chunk is on disk; the interrupted one is not.
1800        assert_eq!(
1801            queries::library_stats(&db.conn).unwrap().total_tracks,
1802            CHUNK_SIZE as i64
1803        );
1804
1805        // And the next run picks up where it left off instead of restarting.
1806        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1807        assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
1808        assert_eq!(r.added, 6 - CHUNK_SIZE);
1809        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
1810    }
1811
1812    #[test]
1813    fn failing_file_is_named_and_the_scan_continues() {
1814        let dir = tempfile::tempdir().unwrap();
1815        let music_dir = dir.path().join("music");
1816        std::fs::create_dir_all(&music_dir).unwrap();
1817        test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
1818        let broken = music_dir.join("broken.flac");
1819        std::fs::write(&broken, b"").unwrap();
1820
1821        let db = test_db(dir.path());
1822        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1823
1824        assert_eq!(r.added, 1, "the good file must still be indexed");
1825        assert_eq!(r.errors.len(), 1);
1826        assert_eq!(r.errors[0].0, broken, "the failing file must be named");
1827        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1828    }
1829
1830    #[test]
1831    fn a_panicking_tag_read_becomes_an_error() {
1832        let path = Path::new("/music/hostile.mp3");
1833        let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
1834        assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
1835    }
1836
1837    #[test]
1838    fn scan_folder_updates_modified_files() {
1839        let dir = tempfile::tempdir().unwrap();
1840        let music_dir = dir.path().join("music");
1841        std::fs::create_dir_all(&music_dir).unwrap();
1842
1843        let wav_path = music_dir.join("modified.wav");
1844        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1845
1846        let db = test_db(dir.path());
1847
1848        // First scan.
1849        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1850        assert_eq!(r1.added, 1);
1851
1852        // Modify the file (rewrite with different duration → different size + mtime).
1853        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
1854        std::thread::sleep(std::time::Duration::from_millis(1100));
1855        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
1856
1857        // Second scan: should detect the modification.
1858        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1859        assert_eq!(r2.updated, 1, "modified file should be re-indexed");
1860        assert_eq!(r2.added, 0, "the row already exists");
1861        assert_eq!(r2.skipped, 0, "modified file should not be skipped");
1862    }
1863
1864    #[test]
1865    fn a_drop_and_a_scan_spell_a_file_the_same_way() {
1866        use unicode_normalization::UnicodeNormalization;
1867        let dir = tempfile::tempdir().unwrap();
1868        let nfd: String = "Roman Flügel".nfd().collect();
1869        let nfc: String = "Roman Flügel".nfc().collect();
1870        let music_dir = dir.path().join(&nfd);
1871        std::fs::create_dir_all(&music_dir).unwrap();
1872        test_utils::generate_wav(&music_dir.join("softice.wav"), 44100, 1, 0.2, 16);
1873        // Where the filesystem tells the two spellings apart, as Linux's do,
1874        // the precomposed path names no file, and there is nothing to drop.
1875        if !dir.path().join(&nfc).exists() {
1876            return;
1877        }
1878        let db = test_db(dir.path());
1879
1880        // Dropped from Finder: the path arrives precomposed.
1881        let dropped = import_paths(&db, &[dir.path().join(&nfc).join("softice.wav")]);
1882        assert_eq!(dropped.added, 1, "errors: {:?}", dropped.errors);
1883
1884        // Rescanned from the folder: the walker reads the directory's own bytes.
1885        let scanned = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1886        assert_eq!(scanned.added, 0, "the same file, not a second one");
1887
1888        let stats = queries::library_stats(&db.conn).unwrap();
1889        assert_eq!(stats.total_tracks, 1);
1890        let stored: String = db
1891            .conn
1892            .query_row("SELECT path FROM tracks", [], |r| r.get(0))
1893            .unwrap();
1894        assert_eq!(stored, music_dir.join("softice.wav").to_string_lossy());
1895    }
1896}